Commit 632897232c7f7bf429932216cdfdf29ddd7b5430
1 parent
395c9e97
提交
Showing
48 changed files
with
3528 additions
and
1669 deletions
美国版/Food Labeling Management App UniApp/src/components/SideMenu.vue
| @@ -111,15 +111,6 @@ const items = [ | @@ -111,15 +111,6 @@ const items = [ | ||
| 111 | { key: 'home', path: '/pages/index/index', icon: 'home', labelKey: 'Home' }, | 111 | { key: 'home', path: '/pages/index/index', icon: 'home', labelKey: 'Home' }, |
| 112 | { key: 'Labeling', path: '/pages/labels/labels', icon: 'tag', labelKey: 'Labeling' }, | 112 | { key: 'Labeling', path: '/pages/labels/labels', icon: 'tag', labelKey: 'Labeling' }, |
| 113 | { | 113 | { |
| 114 | - key: 'categories', | ||
| 115 | - icon: 'squares', | ||
| 116 | - labelKey: 'categories.menuGroup', | ||
| 117 | - children: [ | ||
| 118 | - { path: '/pages/categories/product-categories', labelKey: 'categories.productCategories' }, | ||
| 119 | - { path: '/pages/categories/label-categories', labelKey: 'categories.labelCategories' }, | ||
| 120 | - ], | ||
| 121 | - }, | ||
| 122 | - { | ||
| 123 | key: 'report', | 114 | key: 'report', |
| 124 | icon: 'fileText', | 115 | icon: 'fileText', |
| 125 | labelKey: 'more.report', | 116 | labelKey: 'more.report', |
| @@ -147,12 +138,6 @@ watch( | @@ -147,12 +138,6 @@ watch( | ||
| 147 | if (currentPath.value.startsWith('/pages/more/print-log') || currentPath.value.startsWith('/pages/more/label-report')) { | 138 | if (currentPath.value.startsWith('/pages/more/print-log') || currentPath.value.startsWith('/pages/more/label-report')) { |
| 148 | expandedKey.value = 'report' | 139 | expandedKey.value = 'report' |
| 149 | } | 140 | } |
| 150 | - if ( | ||
| 151 | - currentPath.value.startsWith('/pages/categories/product-categories') || | ||
| 152 | - currentPath.value.startsWith('/pages/categories/label-categories') | ||
| 153 | - ) { | ||
| 154 | - expandedKey.value = 'categories' | ||
| 155 | - } | ||
| 156 | nextTick(() => { | 141 | nextTick(() => { |
| 157 | animClass.value = 'opening' | 142 | animClass.value = 'opening' |
| 158 | }) | 143 | }) |
美国版/Food Labeling Management App UniApp/src/locales/en.ts
| 1 | export default { | 1 | export default { |
| 2 | Home: 'Home', | 2 | Home: 'Home', |
| 3 | Labeling: 'Labeling', | 3 | Labeling: 'Labeling', |
| 4 | - categories: { | ||
| 5 | - menuGroup: 'Categories', | ||
| 6 | - productCategories: 'Product categories', | ||
| 7 | - labelCategories: 'Label categories', | ||
| 8 | - productTitle: 'Product categories', | ||
| 9 | - labelTitle: 'Label categories', | ||
| 10 | - searchPlaceholder: 'Search by name or code…', | ||
| 11 | - loading: 'Loading…', | ||
| 12 | - empty: 'No categories found', | ||
| 13 | - loadFailed: 'Failed to load', | ||
| 14 | - active: 'Active', | ||
| 15 | - inactive: 'Inactive', | ||
| 16 | - pullMore: 'Scroll for more', | ||
| 17 | - }, | ||
| 18 | common: { back: 'Back', confirm: 'Confirm', cancel: 'Cancel', online: 'Online', loading: 'Loading…' }, | 4 | common: { back: 'Back', confirm: 'Confirm', cancel: 'Cancel', online: 'Online', loading: 'Loading…' }, |
| 19 | login: { | 5 | login: { |
| 20 | appName: 'Food Label System', | 6 | appName: 'Food Label System', |
美国版/Food Labeling Management App UniApp/src/locales/zh.ts
| 1 | export default { | 1 | export default { |
| 2 | Home: '首页', | 2 | Home: '首页', |
| 3 | Labeling: '标签', | 3 | Labeling: '标签', |
| 4 | - categories: { | ||
| 5 | - menuGroup: '分类目录', | ||
| 6 | - productCategories: '产品分类', | ||
| 7 | - labelCategories: '标签分类', | ||
| 8 | - productTitle: '产品分类', | ||
| 9 | - labelTitle: '标签分类', | ||
| 10 | - searchPlaceholder: '按名称或编码搜索…', | ||
| 11 | - loading: '加载中…', | ||
| 12 | - empty: '暂无分类', | ||
| 13 | - loadFailed: '加载失败', | ||
| 14 | - active: '启用', | ||
| 15 | - inactive: '停用', | ||
| 16 | - pullMore: '上拉加载更多', | ||
| 17 | - }, | ||
| 18 | common: { back: '返回', confirm: '确认', cancel: '取消', online: '在线', loading: '加载中…' }, | 4 | common: { back: '返回', confirm: '确认', cancel: '取消', online: '在线', loading: '加载中…' }, |
| 19 | login: { | 5 | login: { |
| 20 | appName: '食品标签系统', | 6 | appName: '食品标签系统', |
美国版/Food Labeling Management App UniApp/src/pages.json
| @@ -29,20 +29,6 @@ | @@ -29,20 +29,6 @@ | ||
| 29 | } | 29 | } |
| 30 | }, | 30 | }, |
| 31 | { | 31 | { |
| 32 | - "path": "pages/categories/product-categories", | ||
| 33 | - "style": { | ||
| 34 | - "navigationBarTitleText": "Product Categories", | ||
| 35 | - "navigationStyle": "custom" | ||
| 36 | - } | ||
| 37 | - }, | ||
| 38 | - { | ||
| 39 | - "path": "pages/categories/label-categories", | ||
| 40 | - "style": { | ||
| 41 | - "navigationBarTitleText": "Label Categories", | ||
| 42 | - "navigationStyle": "custom" | ||
| 43 | - } | ||
| 44 | - }, | ||
| 45 | - { | ||
| 46 | "path": "pages/labels/food-select", | 32 | "path": "pages/labels/food-select", |
| 47 | "style": { | 33 | "style": { |
| 48 | "navigationBarTitleText": "Select Food", | 34 | "navigationBarTitleText": "Select Food", |
美国版/Food Labeling Management App UniApp/src/pages/categories/label-categories.vue deleted
| 1 | -<template> | ||
| 2 | - <view class="page"> | ||
| 3 | - <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }"> | ||
| 4 | - <view class="top-bar"> | ||
| 5 | - <view class="top-left" @click="goBack"> | ||
| 6 | - <AppIcon name="chevronLeft" size="sm" color="white" /> | ||
| 7 | - </view> | ||
| 8 | - <view class="top-center"> | ||
| 9 | - <text class="title">{{ t('categories.labelTitle') }}</text> | ||
| 10 | - </view> | ||
| 11 | - <view class="top-right" /> | ||
| 12 | - </view> | ||
| 13 | - </view> | ||
| 14 | - | ||
| 15 | - <view class="search-box"> | ||
| 16 | - <view class="search-icon-wrap"> | ||
| 17 | - <AppIcon name="search" size="sm" color="gray" /> | ||
| 18 | - </view> | ||
| 19 | - <input | ||
| 20 | - v-model="searchInput" | ||
| 21 | - class="search-input" | ||
| 22 | - :placeholder="t('categories.searchPlaceholder')" | ||
| 23 | - placeholder-class="placeholder" | ||
| 24 | - /> | ||
| 25 | - </view> | ||
| 26 | - | ||
| 27 | - <scroll-view class="list-wrap" scroll-y @scrolltolower="loadMore"> | ||
| 28 | - <view v-if="loading && items.length === 0" class="state"> | ||
| 29 | - <text class="state-text">{{ t('categories.loading') }}</text> | ||
| 30 | - </view> | ||
| 31 | - <view v-else-if="errorText" class="state"> | ||
| 32 | - <text class="state-text">{{ errorText }}</text> | ||
| 33 | - </view> | ||
| 34 | - <view v-else-if="items.length === 0" class="state"> | ||
| 35 | - <text class="state-text">{{ t('categories.empty') }}</text> | ||
| 36 | - </view> | ||
| 37 | - <view v-else class="cards"> | ||
| 38 | - <view v-for="row in items" :key="row.id" class="card"> | ||
| 39 | - <view class="card-icon" :style="cardIconBoxStyle(row)"> | ||
| 40 | - <image | ||
| 41 | - v-if="rowVisual(row).mode === 'image'" | ||
| 42 | - :src="photoUrl(rowVisual(row).imageUrl) || ''" | ||
| 43 | - class="card-icon-img" | ||
| 44 | - mode="aspectFill" | ||
| 45 | - /> | ||
| 46 | - <view v-else-if="rowVisual(row).mode === 'colorText'" class="card-icon-text card-icon-text--on-color"> | ||
| 47 | - <text | ||
| 48 | - class="card-icon-text-inner" | ||
| 49 | - :style="{ color: rowVisual(row).textColor || '#ffffff' }" | ||
| 50 | - >{{ rowVisual(row).text }}</text> | ||
| 51 | - </view> | ||
| 52 | - <view | ||
| 53 | - v-else-if="rowVisual(row).mode === 'color'" | ||
| 54 | - class="card-icon-color" | ||
| 55 | - :style="{ backgroundColor: rowVisual(row).bg }" | ||
| 56 | - /> | ||
| 57 | - <view v-else-if="rowVisual(row).mode === 'text'" class="card-icon-text"> | ||
| 58 | - <text class="card-icon-text-inner">{{ rowVisual(row).text }}</text> | ||
| 59 | - </view> | ||
| 60 | - <view v-else class="card-icon-ph"> | ||
| 61 | - <AppIcon name="tag" size="md" color="gray" /> | ||
| 62 | - </view> | ||
| 63 | - </view> | ||
| 64 | - <view class="card-body"> | ||
| 65 | - <text class="card-name">{{ row.categoryName }}</text> | ||
| 66 | - <text class="card-code">{{ row.categoryCode }}</text> | ||
| 67 | - <view class="card-meta"> | ||
| 68 | - <text class="badge" :class="row.state ? 'on' : 'off'"> | ||
| 69 | - {{ row.state ? t('categories.active') : t('categories.inactive') }} | ||
| 70 | - </text> | ||
| 71 | - <text v-if="row.lastEdited" class="edited">{{ row.lastEdited }}</text> | ||
| 72 | - </view> | ||
| 73 | - </view> | ||
| 74 | - </view> | ||
| 75 | - </view> | ||
| 76 | - <view v-if="loading && items.length > 0" class="footer-loading"> | ||
| 77 | - <text class="state-text">{{ t('categories.loading') }}</text> | ||
| 78 | - </view> | ||
| 79 | - <view v-else-if="hasMorePage && items.length > 0" class="footer-more"> | ||
| 80 | - <text class="hint">{{ t('categories.pullMore') }}</text> | ||
| 81 | - </view> | ||
| 82 | - </scroll-view> | ||
| 83 | - </view> | ||
| 84 | -</template> | ||
| 85 | - | ||
| 86 | -<script setup lang="ts"> | ||
| 87 | -import { ref, watch, computed } from 'vue' | ||
| 88 | -import { onShow } from '@dcloudio/uni-app' | ||
| 89 | -import { useI18n } from 'vue-i18n' | ||
| 90 | -import AppIcon from '../../components/AppIcon.vue' | ||
| 91 | -import { getStatusBarHeight } from '../../utils/statusBar' | ||
| 92 | -import { getAccessToken } from '../../utils/authSession' | ||
| 93 | -import { fetchLabelCategoryPage } from '../../services/labelCategory' | ||
| 94 | -import type { LabelCategoryListItemDto } from '../../types/platformCategories' | ||
| 95 | -import { resolveMediaUrlForApp } from '../../utils/resolveMediaUrl' | ||
| 96 | -import { resolveCategoryButtonVisualFromDto, type CategoryVisualRender } from '../../utils/categoryButtonAppearance' | ||
| 97 | -import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' | ||
| 98 | - | ||
| 99 | -const { t } = useI18n() | ||
| 100 | -const statusBarHeight = getStatusBarHeight() | ||
| 101 | -const PAGE = 50 | ||
| 102 | - | ||
| 103 | -const searchInput = ref('') | ||
| 104 | -const debouncedKeyword = ref('') | ||
| 105 | -const items = ref<LabelCategoryListItemDto[]>([]) | ||
| 106 | -const totalCount = ref(0) | ||
| 107 | -const loading = ref(false) | ||
| 108 | -const errorText = ref('') | ||
| 109 | -let searchTimer: ReturnType<typeof setTimeout> | null = null | ||
| 110 | - | ||
| 111 | -const hasMorePage = computed(() => items.value.length < totalCount.value) | ||
| 112 | -const nextApiPage = ref(1) | ||
| 113 | - | ||
| 114 | -watch(searchInput, () => { | ||
| 115 | - if (searchTimer) clearTimeout(searchTimer) | ||
| 116 | - searchTimer = setTimeout(() => { | ||
| 117 | - debouncedKeyword.value = searchInput.value.trim() | ||
| 118 | - }, 350) | ||
| 119 | -}) | ||
| 120 | - | ||
| 121 | -watch(debouncedKeyword, () => { | ||
| 122 | - resetAndLoad() | ||
| 123 | -}) | ||
| 124 | - | ||
| 125 | -onShow(() => { | ||
| 126 | - if (!getAccessToken()) { | ||
| 127 | - uni.reLaunch({ url: '/pages/login/login' }) | ||
| 128 | - return | ||
| 129 | - } | ||
| 130 | - resetAndLoad() | ||
| 131 | -}) | ||
| 132 | - | ||
| 133 | -function photoUrl(u: string | null | undefined) { | ||
| 134 | - return resolveMediaUrlForApp(u) | ||
| 135 | -} | ||
| 136 | - | ||
| 137 | -function rowVisual(row: LabelCategoryListItemDto): CategoryVisualRender { | ||
| 138 | - return resolveCategoryButtonVisualFromDto(row) | ||
| 139 | -} | ||
| 140 | - | ||
| 141 | -function cardIconBoxStyle(row: LabelCategoryListItemDto): Record<string, string> { | ||
| 142 | - const v = rowVisual(row) | ||
| 143 | - if (v.mode === 'colorText') return { backgroundColor: v.bg } | ||
| 144 | - return {} | ||
| 145 | -} | ||
| 146 | - | ||
| 147 | -function goBack() { | ||
| 148 | - const pages = getCurrentPages() | ||
| 149 | - if (pages.length > 1) uni.navigateBack() | ||
| 150 | - else uni.redirectTo({ url: '/pages/index/index' }) | ||
| 151 | -} | ||
| 152 | - | ||
| 153 | -async function resetAndLoad() { | ||
| 154 | - items.value = [] | ||
| 155 | - totalCount.value = 0 | ||
| 156 | - nextApiPage.value = 1 | ||
| 157 | - await fetchPage(true) | ||
| 158 | -} | ||
| 159 | - | ||
| 160 | -async function fetchPage(replace: boolean) { | ||
| 161 | - loading.value = true | ||
| 162 | - errorText.value = '' | ||
| 163 | - const page = nextApiPage.value | ||
| 164 | - try { | ||
| 165 | - const { items: rows, totalCount: tc } = await fetchLabelCategoryPage({ | ||
| 166 | - skipCount: page, | ||
| 167 | - maxResultCount: PAGE, | ||
| 168 | - keyword: debouncedKeyword.value || undefined, | ||
| 169 | - state: true, | ||
| 170 | - }) | ||
| 171 | - totalCount.value = tc | ||
| 172 | - if (replace) items.value = rows | ||
| 173 | - else items.value = items.value.concat(rows) | ||
| 174 | - nextApiPage.value = page + 1 | ||
| 175 | - } catch (e: unknown) { | ||
| 176 | - if (isUsAppSessionExpiredError(e)) return | ||
| 177 | - errorText.value = e instanceof Error ? e.message : t('categories.loadFailed') | ||
| 178 | - if (replace) items.value = [] | ||
| 179 | - } finally { | ||
| 180 | - loading.value = false | ||
| 181 | - } | ||
| 182 | -} | ||
| 183 | - | ||
| 184 | -async function loadMore() { | ||
| 185 | - if (loading.value || !hasMorePage.value) return | ||
| 186 | - await fetchPage(false) | ||
| 187 | -} | ||
| 188 | -</script> | ||
| 189 | - | ||
| 190 | -<style scoped> | ||
| 191 | -.page { | ||
| 192 | - min-height: 100vh; | ||
| 193 | - background: #f9fafb; | ||
| 194 | - display: flex; | ||
| 195 | - flex-direction: column; | ||
| 196 | -} | ||
| 197 | -.header-hero { | ||
| 198 | - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-dark)); | ||
| 199 | - padding: 16rpx 32rpx 24rpx; | ||
| 200 | -} | ||
| 201 | -.top-bar { | ||
| 202 | - height: 88rpx; | ||
| 203 | - display: flex; | ||
| 204 | - align-items: center; | ||
| 205 | - justify-content: space-between; | ||
| 206 | -} | ||
| 207 | -.top-left, | ||
| 208 | -.top-right { | ||
| 209 | - width: 64rpx; | ||
| 210 | - height: 64rpx; | ||
| 211 | - border-radius: 999rpx; | ||
| 212 | - background: rgba(255, 255, 255, 0.15); | ||
| 213 | - display: flex; | ||
| 214 | - align-items: center; | ||
| 215 | - justify-content: center; | ||
| 216 | -} | ||
| 217 | -.top-center { | ||
| 218 | - flex: 1; | ||
| 219 | - text-align: center; | ||
| 220 | -} | ||
| 221 | -.title { | ||
| 222 | - font-size: 32rpx; | ||
| 223 | - font-weight: 600; | ||
| 224 | - color: #fff; | ||
| 225 | -} | ||
| 226 | -.search-box { | ||
| 227 | - position: relative; | ||
| 228 | - padding: 20rpx 24rpx; | ||
| 229 | - background: #fff; | ||
| 230 | - border-bottom: 1rpx solid #e5e7eb; | ||
| 231 | -} | ||
| 232 | -.search-icon-wrap { | ||
| 233 | - position: absolute; | ||
| 234 | - left: 44rpx; | ||
| 235 | - top: 50%; | ||
| 236 | - transform: translateY(-50%); | ||
| 237 | - z-index: 1; | ||
| 238 | -} | ||
| 239 | -.search-input { | ||
| 240 | - height: 72rpx; | ||
| 241 | - padding-left: 64rpx; | ||
| 242 | - padding-right: 20rpx; | ||
| 243 | - background: #f3f4f6; | ||
| 244 | - border-radius: 16rpx; | ||
| 245 | - font-size: 26rpx; | ||
| 246 | -} | ||
| 247 | -.list-wrap { | ||
| 248 | - flex: 1; | ||
| 249 | - height: 0; | ||
| 250 | - min-height: 400rpx; | ||
| 251 | -} | ||
| 252 | -.state { | ||
| 253 | - padding: 80rpx 32rpx; | ||
| 254 | - text-align: center; | ||
| 255 | -} | ||
| 256 | -.state-text { | ||
| 257 | - font-size: 28rpx; | ||
| 258 | - color: #9ca3af; | ||
| 259 | -} | ||
| 260 | -.cards { | ||
| 261 | - padding: 16rpx 24rpx 48rpx; | ||
| 262 | - display: flex; | ||
| 263 | - flex-direction: column; | ||
| 264 | - gap: 16rpx; | ||
| 265 | -} | ||
| 266 | -.card { | ||
| 267 | - display: flex; | ||
| 268 | - flex-direction: row; | ||
| 269 | - align-items: center; | ||
| 270 | - gap: 20rpx; | ||
| 271 | - background: #fff; | ||
| 272 | - border-radius: 16rpx; | ||
| 273 | - padding: 20rpx; | ||
| 274 | - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); | ||
| 275 | -} | ||
| 276 | -.card-icon { | ||
| 277 | - width: 96rpx; | ||
| 278 | - height: 96rpx; | ||
| 279 | - border-radius: 12rpx; | ||
| 280 | - flex-shrink: 0; | ||
| 281 | - overflow: hidden; | ||
| 282 | - background: #f3f4f6; | ||
| 283 | - display: flex; | ||
| 284 | - align-items: center; | ||
| 285 | - justify-content: center; | ||
| 286 | -} | ||
| 287 | -.card-icon-img { | ||
| 288 | - width: 100%; | ||
| 289 | - height: 100%; | ||
| 290 | -} | ||
| 291 | -.card-icon-color { | ||
| 292 | - width: 100%; | ||
| 293 | - height: 100%; | ||
| 294 | -} | ||
| 295 | -.card-icon-text { | ||
| 296 | - width: 100%; | ||
| 297 | - height: 100%; | ||
| 298 | - display: flex; | ||
| 299 | - align-items: center; | ||
| 300 | - justify-content: center; | ||
| 301 | - padding: 8rpx; | ||
| 302 | -} | ||
| 303 | -.card-icon-text-inner { | ||
| 304 | - max-width: 100%; | ||
| 305 | - font-size: 24rpx; | ||
| 306 | - font-weight: 700; | ||
| 307 | - color: #111827; | ||
| 308 | - overflow: hidden; | ||
| 309 | - text-overflow: ellipsis; | ||
| 310 | - white-space: nowrap; | ||
| 311 | -} | ||
| 312 | -.card-icon-text--on-color .card-icon-text-inner { | ||
| 313 | - white-space: normal; | ||
| 314 | - text-align: center; | ||
| 315 | - line-height: 1.2; | ||
| 316 | - display: -webkit-box; | ||
| 317 | - -webkit-line-clamp: 2; | ||
| 318 | - -webkit-box-orient: vertical; | ||
| 319 | - overflow: hidden; | ||
| 320 | -} | ||
| 321 | -.card-icon-ph { | ||
| 322 | - width: 100%; | ||
| 323 | - height: 100%; | ||
| 324 | - display: flex; | ||
| 325 | - align-items: center; | ||
| 326 | - justify-content: center; | ||
| 327 | -} | ||
| 328 | -.card-body { | ||
| 329 | - flex: 1; | ||
| 330 | - min-width: 0; | ||
| 331 | -} | ||
| 332 | -.card-name { | ||
| 333 | - font-size: 30rpx; | ||
| 334 | - font-weight: 600; | ||
| 335 | - color: #111827; | ||
| 336 | - display: block; | ||
| 337 | -} | ||
| 338 | -.card-code { | ||
| 339 | - font-size: 24rpx; | ||
| 340 | - color: #6b7280; | ||
| 341 | - display: block; | ||
| 342 | - margin-top: 6rpx; | ||
| 343 | -} | ||
| 344 | -.card-meta { | ||
| 345 | - display: flex; | ||
| 346 | - flex-wrap: wrap; | ||
| 347 | - align-items: center; | ||
| 348 | - gap: 12rpx; | ||
| 349 | - margin-top: 10rpx; | ||
| 350 | -} | ||
| 351 | -.badge { | ||
| 352 | - font-size: 22rpx; | ||
| 353 | - padding: 4rpx 12rpx; | ||
| 354 | - border-radius: 8rpx; | ||
| 355 | -} | ||
| 356 | -.badge.on { | ||
| 357 | - background: #dcfce7; | ||
| 358 | - color: #166534; | ||
| 359 | -} | ||
| 360 | -.badge.off { | ||
| 361 | - background: #fee2e2; | ||
| 362 | - color: #991b1b; | ||
| 363 | -} | ||
| 364 | -.edited { | ||
| 365 | - font-size: 22rpx; | ||
| 366 | - color: #9ca3af; | ||
| 367 | -} | ||
| 368 | -.footer-loading, | ||
| 369 | -.footer-more { | ||
| 370 | - padding: 24rpx; | ||
| 371 | - text-align: center; | ||
| 372 | -} | ||
| 373 | -.hint { | ||
| 374 | - font-size: 24rpx; | ||
| 375 | - color: #9ca3af; | ||
| 376 | -} | ||
| 377 | -</style> |
美国版/Food Labeling Management App UniApp/src/pages/categories/product-categories.vue deleted
| 1 | -<template> | ||
| 2 | - <view class="page"> | ||
| 3 | - <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }"> | ||
| 4 | - <view class="top-bar"> | ||
| 5 | - <view class="top-left" @click="goBack"> | ||
| 6 | - <AppIcon name="chevronLeft" size="sm" color="white" /> | ||
| 7 | - </view> | ||
| 8 | - <view class="top-center"> | ||
| 9 | - <text class="title">{{ t('categories.productTitle') }}</text> | ||
| 10 | - </view> | ||
| 11 | - <view class="top-right" /> | ||
| 12 | - </view> | ||
| 13 | - </view> | ||
| 14 | - | ||
| 15 | - <view class="search-box"> | ||
| 16 | - <view class="search-icon-wrap"> | ||
| 17 | - <AppIcon name="search" size="sm" color="gray" /> | ||
| 18 | - </view> | ||
| 19 | - <input | ||
| 20 | - v-model="searchInput" | ||
| 21 | - class="search-input" | ||
| 22 | - :placeholder="t('categories.searchPlaceholder')" | ||
| 23 | - placeholder-class="placeholder" | ||
| 24 | - /> | ||
| 25 | - </view> | ||
| 26 | - | ||
| 27 | - <scroll-view class="list-wrap" scroll-y @scrolltolower="loadMore"> | ||
| 28 | - <view v-if="loading && items.length === 0" class="state"> | ||
| 29 | - <text class="state-text">{{ t('categories.loading') }}</text> | ||
| 30 | - </view> | ||
| 31 | - <view v-else-if="errorText" class="state"> | ||
| 32 | - <text class="state-text">{{ errorText }}</text> | ||
| 33 | - </view> | ||
| 34 | - <view v-else-if="items.length === 0" class="state"> | ||
| 35 | - <text class="state-text">{{ t('categories.empty') }}</text> | ||
| 36 | - </view> | ||
| 37 | - <view v-else class="cards"> | ||
| 38 | - <view v-for="row in items" :key="row.id" class="card"> | ||
| 39 | - <view class="card-icon" :style="cardIconBoxStyle(row)"> | ||
| 40 | - <image | ||
| 41 | - v-if="rowVisual(row).mode === 'image'" | ||
| 42 | - :src="photoUrl(rowVisual(row).imageUrl) || ''" | ||
| 43 | - class="card-icon-img" | ||
| 44 | - mode="aspectFill" | ||
| 45 | - /> | ||
| 46 | - <view v-else-if="rowVisual(row).mode === 'colorText'" class="card-icon-text card-icon-text--on-color"> | ||
| 47 | - <text | ||
| 48 | - class="card-icon-text-inner" | ||
| 49 | - :style="{ color: rowVisual(row).textColor || '#ffffff' }" | ||
| 50 | - >{{ rowVisual(row).text }}</text> | ||
| 51 | - </view> | ||
| 52 | - <view | ||
| 53 | - v-else-if="rowVisual(row).mode === 'color'" | ||
| 54 | - class="card-icon-color" | ||
| 55 | - :style="{ backgroundColor: rowVisual(row).bg }" | ||
| 56 | - /> | ||
| 57 | - <view v-else-if="rowVisual(row).mode === 'text'" class="card-icon-text"> | ||
| 58 | - <text class="card-icon-text-inner">{{ rowVisual(row).text }}</text> | ||
| 59 | - </view> | ||
| 60 | - <view v-else class="card-icon-ph"> | ||
| 61 | - <AppIcon name="food" size="md" color="gray" /> | ||
| 62 | - </view> | ||
| 63 | - </view> | ||
| 64 | - <view class="card-body"> | ||
| 65 | - <text class="card-name">{{ row.categoryName }}</text> | ||
| 66 | - <text class="card-code">{{ row.categoryCode }}</text> | ||
| 67 | - <view class="card-meta"> | ||
| 68 | - <text class="badge" :class="row.state ? 'on' : 'off'"> | ||
| 69 | - {{ row.state ? t('categories.active') : t('categories.inactive') }} | ||
| 70 | - </text> | ||
| 71 | - <text v-if="row.lastEdited" class="edited">{{ row.lastEdited }}</text> | ||
| 72 | - </view> | ||
| 73 | - </view> | ||
| 74 | - </view> | ||
| 75 | - </view> | ||
| 76 | - <view v-if="loading && items.length > 0" class="footer-loading"> | ||
| 77 | - <text class="state-text">{{ t('categories.loading') }}</text> | ||
| 78 | - </view> | ||
| 79 | - <view v-else-if="hasMorePage && items.length > 0" class="footer-more"> | ||
| 80 | - <text class="hint">{{ t('categories.pullMore') }}</text> | ||
| 81 | - </view> | ||
| 82 | - </scroll-view> | ||
| 83 | - </view> | ||
| 84 | -</template> | ||
| 85 | - | ||
| 86 | -<script setup lang="ts"> | ||
| 87 | -import { ref, watch, computed } from 'vue' | ||
| 88 | -import { onShow } from '@dcloudio/uni-app' | ||
| 89 | -import { useI18n } from 'vue-i18n' | ||
| 90 | -import AppIcon from '../../components/AppIcon.vue' | ||
| 91 | -import { getStatusBarHeight } from '../../utils/statusBar' | ||
| 92 | -import { getAccessToken } from '../../utils/authSession' | ||
| 93 | -import { fetchProductCategoryPage } from '../../services/productCategory' | ||
| 94 | -import type { ProductCategoryListItemDto } from '../../types/platformCategories' | ||
| 95 | -import { resolveMediaUrlForApp } from '../../utils/resolveMediaUrl' | ||
| 96 | -import { resolveCategoryButtonVisualFromDto, type CategoryVisualRender } from '../../utils/categoryButtonAppearance' | ||
| 97 | -import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' | ||
| 98 | - | ||
| 99 | -const { t } = useI18n() | ||
| 100 | -const statusBarHeight = getStatusBarHeight() | ||
| 101 | -const PAGE = 50 | ||
| 102 | - | ||
| 103 | -const searchInput = ref('') | ||
| 104 | -const debouncedKeyword = ref('') | ||
| 105 | -const items = ref<ProductCategoryListItemDto[]>([]) | ||
| 106 | -const totalCount = ref(0) | ||
| 107 | -const loading = ref(false) | ||
| 108 | -const errorText = ref('') | ||
| 109 | -let searchTimer: ReturnType<typeof setTimeout> | null = null | ||
| 110 | - | ||
| 111 | -const hasMorePage = computed(() => items.value.length < totalCount.value) | ||
| 112 | -/** 下一请求页码(SkipCount,从 1 起) */ | ||
| 113 | -const nextApiPage = ref(1) | ||
| 114 | - | ||
| 115 | -watch(searchInput, () => { | ||
| 116 | - if (searchTimer) clearTimeout(searchTimer) | ||
| 117 | - searchTimer = setTimeout(() => { | ||
| 118 | - debouncedKeyword.value = searchInput.value.trim() | ||
| 119 | - }, 350) | ||
| 120 | -}) | ||
| 121 | - | ||
| 122 | -watch(debouncedKeyword, () => { | ||
| 123 | - resetAndLoad() | ||
| 124 | -}) | ||
| 125 | - | ||
| 126 | -onShow(() => { | ||
| 127 | - if (!getAccessToken()) { | ||
| 128 | - uni.reLaunch({ url: '/pages/login/login' }) | ||
| 129 | - return | ||
| 130 | - } | ||
| 131 | - resetAndLoad() | ||
| 132 | -}) | ||
| 133 | - | ||
| 134 | -function photoUrl(u: string | null | undefined) { | ||
| 135 | - return resolveMediaUrlForApp(u) | ||
| 136 | -} | ||
| 137 | - | ||
| 138 | -function rowVisual(row: ProductCategoryListItemDto): CategoryVisualRender { | ||
| 139 | - return resolveCategoryButtonVisualFromDto(row) | ||
| 140 | -} | ||
| 141 | - | ||
| 142 | -function cardIconBoxStyle(row: ProductCategoryListItemDto): Record<string, string> { | ||
| 143 | - const v = rowVisual(row) | ||
| 144 | - if (v.mode === 'colorText') return { backgroundColor: v.bg } | ||
| 145 | - return {} | ||
| 146 | -} | ||
| 147 | - | ||
| 148 | -function goBack() { | ||
| 149 | - const pages = getCurrentPages() | ||
| 150 | - if (pages.length > 1) uni.navigateBack() | ||
| 151 | - else uni.redirectTo({ url: '/pages/index/index' }) | ||
| 152 | -} | ||
| 153 | - | ||
| 154 | -async function resetAndLoad() { | ||
| 155 | - items.value = [] | ||
| 156 | - totalCount.value = 0 | ||
| 157 | - nextApiPage.value = 1 | ||
| 158 | - await fetchPage(true) | ||
| 159 | -} | ||
| 160 | - | ||
| 161 | -async function fetchPage(replace: boolean) { | ||
| 162 | - loading.value = true | ||
| 163 | - errorText.value = '' | ||
| 164 | - const page = nextApiPage.value | ||
| 165 | - try { | ||
| 166 | - const { items: rows, totalCount: tc } = await fetchProductCategoryPage({ | ||
| 167 | - skipCount: page, | ||
| 168 | - maxResultCount: PAGE, | ||
| 169 | - keyword: debouncedKeyword.value || undefined, | ||
| 170 | - state: true, | ||
| 171 | - }) | ||
| 172 | - totalCount.value = tc | ||
| 173 | - if (replace) items.value = rows | ||
| 174 | - else items.value = items.value.concat(rows) | ||
| 175 | - nextApiPage.value = page + 1 | ||
| 176 | - } catch (e: unknown) { | ||
| 177 | - if (isUsAppSessionExpiredError(e)) return | ||
| 178 | - errorText.value = e instanceof Error ? e.message : t('categories.loadFailed') | ||
| 179 | - if (replace) items.value = [] | ||
| 180 | - } finally { | ||
| 181 | - loading.value = false | ||
| 182 | - } | ||
| 183 | -} | ||
| 184 | - | ||
| 185 | -async function loadMore() { | ||
| 186 | - if (loading.value || !hasMorePage.value) return | ||
| 187 | - await fetchPage(false) | ||
| 188 | -} | ||
| 189 | -</script> | ||
| 190 | - | ||
| 191 | -<style scoped> | ||
| 192 | -.page { | ||
| 193 | - min-height: 100vh; | ||
| 194 | - background: #f9fafb; | ||
| 195 | - display: flex; | ||
| 196 | - flex-direction: column; | ||
| 197 | -} | ||
| 198 | -.header-hero { | ||
| 199 | - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-dark)); | ||
| 200 | - padding: 16rpx 32rpx 24rpx; | ||
| 201 | -} | ||
| 202 | -.top-bar { | ||
| 203 | - height: 88rpx; | ||
| 204 | - display: flex; | ||
| 205 | - align-items: center; | ||
| 206 | - justify-content: space-between; | ||
| 207 | -} | ||
| 208 | -.top-left, | ||
| 209 | -.top-right { | ||
| 210 | - width: 64rpx; | ||
| 211 | - height: 64rpx; | ||
| 212 | - border-radius: 999rpx; | ||
| 213 | - background: rgba(255, 255, 255, 0.15); | ||
| 214 | - display: flex; | ||
| 215 | - align-items: center; | ||
| 216 | - justify-content: center; | ||
| 217 | -} | ||
| 218 | -.top-center { | ||
| 219 | - flex: 1; | ||
| 220 | - text-align: center; | ||
| 221 | -} | ||
| 222 | -.title { | ||
| 223 | - font-size: 32rpx; | ||
| 224 | - font-weight: 600; | ||
| 225 | - color: #fff; | ||
| 226 | -} | ||
| 227 | -.search-box { | ||
| 228 | - position: relative; | ||
| 229 | - padding: 20rpx 24rpx; | ||
| 230 | - background: #fff; | ||
| 231 | - border-bottom: 1rpx solid #e5e7eb; | ||
| 232 | -} | ||
| 233 | -.search-icon-wrap { | ||
| 234 | - position: absolute; | ||
| 235 | - left: 44rpx; | ||
| 236 | - top: 50%; | ||
| 237 | - transform: translateY(-50%); | ||
| 238 | - z-index: 1; | ||
| 239 | -} | ||
| 240 | -.search-input { | ||
| 241 | - height: 72rpx; | ||
| 242 | - padding-left: 64rpx; | ||
| 243 | - padding-right: 20rpx; | ||
| 244 | - background: #f3f4f6; | ||
| 245 | - border-radius: 16rpx; | ||
| 246 | - font-size: 26rpx; | ||
| 247 | -} | ||
| 248 | -.list-wrap { | ||
| 249 | - flex: 1; | ||
| 250 | - height: 0; | ||
| 251 | - min-height: 400rpx; | ||
| 252 | -} | ||
| 253 | -.state { | ||
| 254 | - padding: 80rpx 32rpx; | ||
| 255 | - text-align: center; | ||
| 256 | -} | ||
| 257 | -.state-text { | ||
| 258 | - font-size: 28rpx; | ||
| 259 | - color: #9ca3af; | ||
| 260 | -} | ||
| 261 | -.cards { | ||
| 262 | - padding: 16rpx 24rpx 48rpx; | ||
| 263 | - display: flex; | ||
| 264 | - flex-direction: column; | ||
| 265 | - gap: 16rpx; | ||
| 266 | -} | ||
| 267 | -.card { | ||
| 268 | - display: flex; | ||
| 269 | - flex-direction: row; | ||
| 270 | - align-items: center; | ||
| 271 | - gap: 20rpx; | ||
| 272 | - background: #fff; | ||
| 273 | - border-radius: 16rpx; | ||
| 274 | - padding: 20rpx; | ||
| 275 | - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); | ||
| 276 | -} | ||
| 277 | -.card-icon { | ||
| 278 | - width: 96rpx; | ||
| 279 | - height: 96rpx; | ||
| 280 | - border-radius: 12rpx; | ||
| 281 | - flex-shrink: 0; | ||
| 282 | - overflow: hidden; | ||
| 283 | - background: #f3f4f6; | ||
| 284 | - display: flex; | ||
| 285 | - align-items: center; | ||
| 286 | - justify-content: center; | ||
| 287 | -} | ||
| 288 | -.card-icon-img { | ||
| 289 | - width: 100%; | ||
| 290 | - height: 100%; | ||
| 291 | -} | ||
| 292 | -.card-icon-color { | ||
| 293 | - width: 100%; | ||
| 294 | - height: 100%; | ||
| 295 | -} | ||
| 296 | -.card-icon-text { | ||
| 297 | - width: 100%; | ||
| 298 | - height: 100%; | ||
| 299 | - display: flex; | ||
| 300 | - align-items: center; | ||
| 301 | - justify-content: center; | ||
| 302 | - padding: 8rpx; | ||
| 303 | -} | ||
| 304 | -.card-icon-text-inner { | ||
| 305 | - max-width: 100%; | ||
| 306 | - font-size: 24rpx; | ||
| 307 | - font-weight: 700; | ||
| 308 | - color: #111827; | ||
| 309 | - overflow: hidden; | ||
| 310 | - text-overflow: ellipsis; | ||
| 311 | - white-space: nowrap; | ||
| 312 | -} | ||
| 313 | -.card-icon-text--on-color .card-icon-text-inner { | ||
| 314 | - white-space: normal; | ||
| 315 | - text-align: center; | ||
| 316 | - line-height: 1.2; | ||
| 317 | - display: -webkit-box; | ||
| 318 | - -webkit-line-clamp: 2; | ||
| 319 | - -webkit-box-orient: vertical; | ||
| 320 | - overflow: hidden; | ||
| 321 | -} | ||
| 322 | -.card-icon-ph { | ||
| 323 | - width: 100%; | ||
| 324 | - height: 100%; | ||
| 325 | - display: flex; | ||
| 326 | - align-items: center; | ||
| 327 | - justify-content: center; | ||
| 328 | -} | ||
| 329 | -.card-body { | ||
| 330 | - flex: 1; | ||
| 331 | - min-width: 0; | ||
| 332 | -} | ||
| 333 | -.card-name { | ||
| 334 | - font-size: 30rpx; | ||
| 335 | - font-weight: 600; | ||
| 336 | - color: #111827; | ||
| 337 | - display: block; | ||
| 338 | -} | ||
| 339 | -.card-code { | ||
| 340 | - font-size: 24rpx; | ||
| 341 | - color: #6b7280; | ||
| 342 | - display: block; | ||
| 343 | - margin-top: 6rpx; | ||
| 344 | -} | ||
| 345 | -.card-meta { | ||
| 346 | - display: flex; | ||
| 347 | - flex-wrap: wrap; | ||
| 348 | - align-items: center; | ||
| 349 | - gap: 12rpx; | ||
| 350 | - margin-top: 10rpx; | ||
| 351 | -} | ||
| 352 | -.badge { | ||
| 353 | - font-size: 22rpx; | ||
| 354 | - padding: 4rpx 12rpx; | ||
| 355 | - border-radius: 8rpx; | ||
| 356 | -} | ||
| 357 | -.badge.on { | ||
| 358 | - background: #dcfce7; | ||
| 359 | - color: #166534; | ||
| 360 | -} | ||
| 361 | -.badge.off { | ||
| 362 | - background: #fee2e2; | ||
| 363 | - color: #991b1b; | ||
| 364 | -} | ||
| 365 | -.edited { | ||
| 366 | - font-size: 22rpx; | ||
| 367 | - color: #9ca3af; | ||
| 368 | -} | ||
| 369 | -.footer-loading, | ||
| 370 | -.footer-more { | ||
| 371 | - padding: 24rpx; | ||
| 372 | - text-align: center; | ||
| 373 | -} | ||
| 374 | -.hint { | ||
| 375 | - font-size: 24rpx; | ||
| 376 | - color: #9ca3af; | ||
| 377 | -} | ||
| 378 | -</style> |
美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue
| @@ -51,7 +51,16 @@ | @@ -51,7 +51,16 @@ | ||
| 51 | </view> | 51 | </view> |
| 52 | 52 | ||
| 53 | <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }"> | 53 | <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }"> |
| 54 | + <view v-if="!loading && stores.length === 0" class="bottom-actions-row"> | ||
| 55 | + <view class="back-btn" @click="handleBackToLogin"> | ||
| 56 | + <text class="back-btn-text">{{ t('common.back') }}</text> | ||
| 57 | + </view> | ||
| 58 | + <view class="confirm-btn disabled"> | ||
| 59 | + <text class="confirm-btn-text">{{ t('common.confirm') }}</text> | ||
| 60 | + </view> | ||
| 61 | + </view> | ||
| 54 | <view | 62 | <view |
| 63 | + v-else | ||
| 55 | class="confirm-btn" | 64 | class="confirm-btn" |
| 56 | :class="{ disabled: !selectedStore || loading }" | 65 | :class="{ disabled: !selectedStore || loading }" |
| 57 | @click="handleConfirm" | 66 | @click="handleConfirm" |
| @@ -71,7 +80,7 @@ import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' | @@ -71,7 +80,7 @@ import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' | ||
| 71 | import { usAppFetchMyLocations } from '../../services/usAppAuth' | 80 | import { usAppFetchMyLocations } from '../../services/usAppAuth' |
| 72 | import type { UsAppBoundLocationDto } from '../../types/usAppBound' | 81 | import type { UsAppBoundLocationDto } from '../../types/usAppBound' |
| 73 | import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' | 82 | import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' |
| 74 | -import { setBoundLocations, getBoundLocations } from '../../utils/authSession' | 83 | +import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession' |
| 75 | import { switchStore } from '../../utils/stores' | 84 | import { switchStore } from '../../utils/stores' |
| 76 | 85 | ||
| 77 | const { t } = useI18n() | 86 | const { t } = useI18n() |
| @@ -111,6 +120,11 @@ onShow(() => { | @@ -111,6 +120,11 @@ onShow(() => { | ||
| 111 | applyList(getBoundLocations()) | 120 | applyList(getBoundLocations()) |
| 112 | }) | 121 | }) |
| 113 | 122 | ||
| 123 | +const handleBackToLogin = () => { | ||
| 124 | + clearAuthSession() | ||
| 125 | + uni.redirectTo({ url: '/pages/login/login' }) | ||
| 126 | +} | ||
| 127 | + | ||
| 114 | const handleConfirm = () => { | 128 | const handleConfirm = () => { |
| 115 | if (loading.value || !selectedStore.value) { | 129 | if (loading.value || !selectedStore.value) { |
| 116 | if (!selectedStore.value) { | 130 | if (!selectedStore.value) { |
| @@ -277,6 +291,35 @@ const handleConfirm = () => { | @@ -277,6 +291,35 @@ const handleConfirm = () => { | ||
| 277 | border-top: 1rpx solid #e5e7eb; | 291 | border-top: 1rpx solid #e5e7eb; |
| 278 | } | 292 | } |
| 279 | 293 | ||
| 294 | +.bottom-actions-row { | ||
| 295 | + display: flex; | ||
| 296 | + align-items: stretch; | ||
| 297 | + gap: 24rpx; | ||
| 298 | +} | ||
| 299 | + | ||
| 300 | +.back-btn { | ||
| 301 | + flex: 1; | ||
| 302 | + height: 96rpx; | ||
| 303 | + border-radius: 16rpx; | ||
| 304 | + display: flex; | ||
| 305 | + align-items: center; | ||
| 306 | + justify-content: center; | ||
| 307 | + border: 3rpx solid var(--theme-primary); | ||
| 308 | + background: #ffffff; | ||
| 309 | + box-sizing: border-box; | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +.back-btn-text { | ||
| 313 | + font-size: 32rpx; | ||
| 314 | + font-weight: 600; | ||
| 315 | + color: var(--theme-primary); | ||
| 316 | + line-height: 1; | ||
| 317 | +} | ||
| 318 | + | ||
| 319 | +.bottom-actions-row .confirm-btn { | ||
| 320 | + flex: 1; | ||
| 321 | +} | ||
| 322 | + | ||
| 280 | .confirm-btn { | 323 | .confirm-btn { |
| 281 | width: 100%; | 324 | width: 100%; |
| 282 | height: 96rpx; | 325 | height: 96rpx; |
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
| 1 | import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' | 1 | import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' |
| 2 | import { resolveTemplateDefaultValueForElement } from './printInputOffset' | 2 | import { resolveTemplateDefaultValueForElement } from './printInputOffset' |
| 3 | +import { applyNutritionDefaultJsonToConfig } from './nutritionDefaultsMerge' | ||
| 3 | 4 | ||
| 4 | function asRecord(v: unknown): Record<string, unknown> { | 5 | function asRecord(v: unknown): Record<string, unknown> { |
| 5 | if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown> | 6 | if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown> |
| @@ -285,6 +286,17 @@ export function applyTemplateProductDefaultValuesToTemplate( | @@ -285,6 +286,17 @@ export function applyTemplateProductDefaultValuesToTemplate( | ||
| 285 | return { ...el, config: cfg } | 286 | return { ...el, config: cfg } |
| 286 | } | 287 | } |
| 287 | 288 | ||
| 289 | + if (type === 'NUTRITION') { | ||
| 290 | + const s = String(v).trim() | ||
| 291 | + if (s.startsWith('{')) { | ||
| 292 | + const merged = applyNutritionDefaultJsonToConfig(cfg, s) | ||
| 293 | + return { ...el, config: merged } | ||
| 294 | + } | ||
| 295 | + cfg.text = s | ||
| 296 | + cfg.Text = s | ||
| 297 | + return { ...el, config: cfg } | ||
| 298 | + } | ||
| 299 | + | ||
| 288 | cfg.text = v | 300 | cfg.text = v |
| 289 | cfg.Text = v | 301 | cfg.Text = v |
| 290 | return { ...el, config: cfg } | 302 | return { ...el, config: cfg } |
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/nutritionDefaultsMerge.ts
0 → 100644
| 1 | +/** | ||
| 2 | + * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。 | ||
| 3 | + */ | ||
| 4 | +export function applyNutritionDefaultJsonToConfig( | ||
| 5 | + baseCfg: Record<string, unknown>, | ||
| 6 | + jsonStr: string, | ||
| 7 | +): Record<string, unknown> { | ||
| 8 | + const t = String(jsonStr ?? "").trim(); | ||
| 9 | + if (!t.startsWith("{")) return baseCfg; | ||
| 10 | + let manual: Record<string, string> = {}; | ||
| 11 | + try { | ||
| 12 | + manual = JSON.parse(t) as Record<string, string>; | ||
| 13 | + } catch { | ||
| 14 | + return baseCfg; | ||
| 15 | + } | ||
| 16 | + const out: Record<string, unknown> = { ...baseCfg }; | ||
| 17 | + for (const [k, val] of Object.entries(manual)) { | ||
| 18 | + const v = String(val ?? "").trim(); | ||
| 19 | + if (k === "calories") { | ||
| 20 | + if (v) out.calories = v; | ||
| 21 | + continue; | ||
| 22 | + } | ||
| 23 | + if (k === "servingsPerContainer") { | ||
| 24 | + out.servingsPerContainer = v; | ||
| 25 | + continue; | ||
| 26 | + } | ||
| 27 | + if (k === "servingSize") { | ||
| 28 | + out.servingSize = v; | ||
| 29 | + continue; | ||
| 30 | + } | ||
| 31 | + if (k.startsWith("extra:") && k.endsWith(":value")) { | ||
| 32 | + const id = k.slice("extra:".length, -":value".length); | ||
| 33 | + const arr = Array.isArray(out.extraNutrients) | ||
| 34 | + ? ([...(out.extraNutrients as Record<string, unknown>[])]) | ||
| 35 | + : []; | ||
| 36 | + const idx = arr.findIndex((row) => String((row as any).id ?? "") === id); | ||
| 37 | + if (idx >= 0) { | ||
| 38 | + arr[idx] = { ...arr[idx], value: v }; | ||
| 39 | + } | ||
| 40 | + out.extraNutrients = arr; | ||
| 41 | + continue; | ||
| 42 | + } | ||
| 43 | + const fr = Array.isArray(out.fixedNutrients) | ||
| 44 | + ? ([...(out.fixedNutrients as Record<string, unknown>[])]) | ||
| 45 | + : []; | ||
| 46 | + const idx = fr.findIndex((row) => String((row as any).key ?? "").trim() === k); | ||
| 47 | + if (idx >= 0) { | ||
| 48 | + fr[idx] = { ...fr[idx], value: v }; | ||
| 49 | + } else { | ||
| 50 | + fr.push({ key: k, label: k, value: v, unit: "" }); | ||
| 51 | + } | ||
| 52 | + out.fixedNutrients = fr; | ||
| 53 | + } | ||
| 54 | + return out; | ||
| 55 | +} |
美国版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.AspNetCore/Mvc/YiConventionalRouteBuilder.cs
| 1 | -using JetBrains.Annotations; | 1 | +using JetBrains.Annotations; |
| 2 | using Microsoft.AspNetCore.Mvc.ApplicationModels; | 2 | using Microsoft.AspNetCore.Mvc.ApplicationModels; |
| 3 | using System.Reflection; | 3 | using System.Reflection; |
| 4 | using Microsoft.Extensions.DependencyInjection; | 4 | using Microsoft.Extensions.DependencyInjection; |
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application.Contracts/IServices/IRoleService.cs
| @@ -23,5 +23,12 @@ namespace Yi.Framework.Rbac.Application.Contracts.IServices | @@ -23,5 +23,12 @@ namespace Yi.Framework.Rbac.Application.Contracts.IServices | ||
| 23 | /// <param name="roleId">角色ID</param> | 23 | /// <param name="roleId">角色ID</param> |
| 24 | /// <returns>角色部门树数据,包含已选中的部门ID和部门树结构</returns> | 24 | /// <returns>角色部门树数据,包含已选中的部门ID和部门树结构</returns> |
| 25 | Task<ActionResult> GetDeptTreeAsync(Guid roleId); | 25 | Task<ActionResult> GetDeptTreeAsync(Guid roleId); |
| 26 | + | ||
| 27 | + /// <summary> | ||
| 28 | + /// 按与列表相同的筛选条件导出角色为 PDF(不分页,上限 5000 条) | ||
| 29 | + /// </summary> | ||
| 30 | + /// <param name="input">RoleName、RoleCode、State;分页字段忽略</param> | ||
| 31 | + /// <returns>PDF 文件流</returns> | ||
| 32 | + Task<IActionResult> ExportPdfAsync(RoleGetListInputVo input); | ||
| 26 | } | 33 | } |
| 27 | } | 34 | } |
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Services/System/RoleService.cs
| 1 | using Mapster; | 1 | using Mapster; |
| 2 | using Microsoft.AspNetCore.Mvc; | 2 | using Microsoft.AspNetCore.Mvc; |
| 3 | +using QuestPDF.Fluent; | ||
| 4 | +using QuestPDF.Helpers; | ||
| 5 | +using QuestPDF.Infrastructure; | ||
| 3 | using SqlSugar; | 6 | using SqlSugar; |
| 7 | +using System.IO; | ||
| 8 | +using Volo.Abp; | ||
| 4 | using Volo.Abp.Application.Dtos; | 9 | using Volo.Abp.Application.Dtos; |
| 5 | using Volo.Abp.Application.Services; | 10 | using Volo.Abp.Application.Services; |
| 6 | using Volo.Abp.Domain.Entities; | 11 | using Volo.Abp.Domain.Entities; |
| @@ -73,6 +78,77 @@ namespace Yi.Framework.Rbac.Application.Services.System | @@ -73,6 +78,77 @@ namespace Yi.Framework.Rbac.Application.Services.System | ||
| 73 | return new PagedResultDto<RoleGetListOutputDto>(total, await MapToGetListOutputDtosAsync(entities)); | 78 | return new PagedResultDto<RoleGetListOutputDto>(total, await MapToGetListOutputDtosAsync(entities)); |
| 74 | } | 79 | } |
| 75 | 80 | ||
| 81 | + /// <inheritdoc /> | ||
| 82 | + public async Task<IActionResult> ExportPdfAsync([FromQuery] RoleGetListInputVo input) | ||
| 83 | + { | ||
| 84 | + QuestPDF.Settings.License = LicenseType.Community; | ||
| 85 | + const int exportPdfMaxRows = 5000; | ||
| 86 | + | ||
| 87 | + var query = BuildRoleListExportQuery(input); | ||
| 88 | + var count = await query.CountAsync(); | ||
| 89 | + if (count > exportPdfMaxRows) | ||
| 90 | + { | ||
| 91 | + throw new UserFriendlyException($"导出数据超过上限 {exportPdfMaxRows} 条,请缩小筛选范围"); | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + var rows = await query.OrderBy(x => x.OrderNum, OrderByType.Desc).Take(exportPdfMaxRows).ToListAsync(); | ||
| 95 | + | ||
| 96 | + var fileName = $"roles_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf"; | ||
| 97 | + | ||
| 98 | + var document = Document.Create(container => | ||
| 99 | + { | ||
| 100 | + container.Page(page => | ||
| 101 | + { | ||
| 102 | + page.Margin(28); | ||
| 103 | + page.DefaultTextStyle(x => x.FontSize(10)); | ||
| 104 | + page.Header().Text("Roles").SemiBold().FontSize(18); | ||
| 105 | + page.Content().PaddingTop(12).Table(table => | ||
| 106 | + { | ||
| 107 | + table.ColumnsDefinition(c => | ||
| 108 | + { | ||
| 109 | + c.RelativeColumn(2f); | ||
| 110 | + c.RelativeColumn(2f); | ||
| 111 | + c.RelativeColumn(1f); | ||
| 112 | + c.RelativeColumn(0.8f); | ||
| 113 | + }); | ||
| 114 | + | ||
| 115 | + static IContainer CellHeader(IContainer c) => | ||
| 116 | + c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold()); | ||
| 117 | + | ||
| 118 | + table.Cell().Element(CellHeader).Text("Role Name"); | ||
| 119 | + table.Cell().Element(CellHeader).Text("Role Code"); | ||
| 120 | + table.Cell().Element(CellHeader).Text("Status"); | ||
| 121 | + table.Cell().Element(CellHeader).Text("Order"); | ||
| 122 | + | ||
| 123 | + foreach (var e in rows) | ||
| 124 | + { | ||
| 125 | + var status = e.State ? "active" : "inactive"; | ||
| 126 | + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) | ||
| 127 | + .Text(e.RoleName ?? string.Empty); | ||
| 128 | + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) | ||
| 129 | + .Text(e.RoleCode ?? string.Empty); | ||
| 130 | + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status); | ||
| 131 | + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5) | ||
| 132 | + .Text(e.OrderNum.ToString()); | ||
| 133 | + } | ||
| 134 | + }); | ||
| 135 | + }); | ||
| 136 | + }); | ||
| 137 | + | ||
| 138 | + var stream = new MemoryStream(); | ||
| 139 | + document.GeneratePdf(stream); | ||
| 140 | + stream.Position = 0; | ||
| 141 | + return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName }; | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + private ISugarQueryable<RoleAggregateRoot> BuildRoleListExportQuery(RoleGetListInputVo input) | ||
| 145 | + { | ||
| 146 | + return _repository._DbQueryable.WhereIF(!string.IsNullOrEmpty(input.RoleCode), | ||
| 147 | + x => x.RoleCode.Contains(input.RoleCode!)) | ||
| 148 | + .WhereIF(!string.IsNullOrEmpty(input.RoleName), x => x.RoleName.Contains(input.RoleName!)) | ||
| 149 | + .WhereIF(input.State is not null, x => x.State == input.State); | ||
| 150 | + } | ||
| 151 | + | ||
| 76 | /// <summary> | 152 | /// <summary> |
| 77 | /// 添加角色 | 153 | /// 添加角色 |
| 78 | /// </summary> | 154 | /// </summary> |
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Yi.Framework.Rbac.Application.csproj
| @@ -9,6 +9,7 @@ | @@ -9,6 +9,7 @@ | ||
| 9 | 9 | ||
| 10 | <ItemGroup> | 10 | <ItemGroup> |
| 11 | <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" /> | 11 | <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" /> |
| 12 | + <PackageReference Include="QuestPDF" Version="2024.12.2" /> | ||
| 12 | <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" /> | 13 | <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" /> |
| 13 | <PackageReference Include="Volo.Abp.BackgroundJobs.Hangfire" Version="$(AbpVersion)" /> | 14 | <PackageReference Include="Volo.Abp.BackgroundJobs.Hangfire" Version="$(AbpVersion)" /> |
| 14 | 15 |
美国版/Food Labeling Management Platform/build/assets/index-BHd3BZos.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-ChVLtgeV.js
0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-DLL5VTnd.css
0 → 100644
| 1 | +.rdp{--rdp-cell-size: 40px;--rdp-caption-font-size: 18px;--rdp-accent-color: #0000ff;--rdp-background-color: #e7edff;--rdp-accent-color-dark: #3003e1;--rdp-background-color-dark: #180270;--rdp-outline: 2px solid var(--rdp-accent-color);--rdp-outline-selected: 3px solid var(--rdp-accent-color);--rdp-selected-color: #fff;margin:1em}.rdp-vhidden{box-sizing:border-box;padding:0;margin:0;background:transparent;border:0;-moz-appearance:none;-webkit-appearance:none;appearance:none;position:absolute!important;top:0;width:1px!important;height:1px!important;padding:0!important;overflow:hidden!important;clip:rect(1px,1px,1px,1px)!important;border:0!important}.rdp-button_reset{appearance:none;position:relative;margin:0;padding:0;cursor:default;color:inherit;background:none;font:inherit;-moz-appearance:none;-webkit-appearance:none}.rdp-button_reset:focus-visible{outline:none}.rdp-button{border:2px solid transparent}.rdp-button[disabled]:not(.rdp-day_selected){opacity:.25}.rdp-button:not([disabled]){cursor:pointer}.rdp-button:focus-visible:not([disabled]){color:inherit;background-color:var(--rdp-background-color);border:var(--rdp-outline)}.rdp-button:hover:not([disabled]):not(.rdp-day_selected){background-color:var(--rdp-background-color)}.rdp-months{display:flex}.rdp-month{margin:0 1em}.rdp-month:first-child{margin-left:0}.rdp-month:last-child{margin-right:0}.rdp-table{margin:0;max-width:calc(var(--rdp-cell-size) * 7);border-collapse:collapse}.rdp-with_weeknumber .rdp-table{max-width:calc(var(--rdp-cell-size) * 8);border-collapse:collapse}.rdp-caption{display:flex;align-items:center;justify-content:space-between;padding:0;text-align:left}.rdp-multiple_months .rdp-caption{position:relative;display:block;text-align:center}.rdp-caption_dropdowns{position:relative;display:inline-flex}.rdp-caption_label{position:relative;z-index:1;display:inline-flex;align-items:center;margin:0;padding:0 .25em;white-space:nowrap;color:currentColor;border:0;border:2px solid transparent;font-family:inherit;font-size:var(--rdp-caption-font-size);font-weight:700}.rdp-nav{white-space:nowrap}.rdp-multiple_months .rdp-caption_start .rdp-nav{position:absolute;top:50%;left:0;transform:translateY(-50%)}.rdp-multiple_months .rdp-caption_end .rdp-nav{position:absolute;top:50%;right:0;transform:translateY(-50%)}.rdp-nav_button{display:inline-flex;align-items:center;justify-content:center;width:var(--rdp-cell-size);height:var(--rdp-cell-size);padding:.25em;border-radius:100%}.rdp-dropdown_year,.rdp-dropdown_month{position:relative;display:inline-flex;align-items:center}.rdp-dropdown{appearance:none;position:absolute;z-index:2;top:0;bottom:0;left:0;width:100%;margin:0;padding:0;cursor:inherit;opacity:0;border:none;background-color:transparent;font-family:inherit;font-size:inherit;line-height:inherit}.rdp-dropdown[disabled]{opacity:unset;color:unset}.rdp-dropdown:focus-visible:not([disabled])+.rdp-caption_label{background-color:var(--rdp-background-color);border:var(--rdp-outline);border-radius:6px}.rdp-dropdown_icon{margin:0 0 0 5px}.rdp-head{border:0}.rdp-head_row,.rdp-row{height:100%}.rdp-head_cell{vertical-align:middle;font-size:.75em;font-weight:700;text-align:center;height:100%;height:var(--rdp-cell-size);padding:0;text-transform:uppercase}.rdp-tbody{border:0}.rdp-tfoot{margin:.5em}.rdp-cell{width:var(--rdp-cell-size);height:100%;height:var(--rdp-cell-size);padding:0;text-align:center}.rdp-weeknumber{font-size:.75em}.rdp-weeknumber,.rdp-day{display:flex;overflow:hidden;align-items:center;justify-content:center;box-sizing:border-box;width:var(--rdp-cell-size);max-width:var(--rdp-cell-size);height:var(--rdp-cell-size);margin:0;border:2px solid transparent;border-radius:100%}.rdp-day_today:not(.rdp-day_outside){font-weight:700}.rdp-day_selected,.rdp-day_selected:focus-visible,.rdp-day_selected:hover{color:var(--rdp-selected-color);opacity:1;background-color:var(--rdp-accent-color)}.rdp-day_outside{opacity:.5}.rdp-day_selected:focus-visible{outline:var(--rdp-outline);outline-offset:2px;z-index:1}.rdp:not([dir=rtl]) .rdp-day_range_start:not(.rdp-day_range_end){border-top-right-radius:0;border-bottom-right-radius:0}.rdp:not([dir=rtl]) .rdp-day_range_end:not(.rdp-day_range_start){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_start:not(.rdp-day_range_end){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_end:not(.rdp-day_range_start){border-top-right-radius:0;border-bottom-right-radius:0}.rdp-day_range_end.rdp-day_range_start{border-radius:100%}.rdp-day_range_middle{border-radius:0}/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-space-x-reverse: 0;--tw-border-style: solid;--tw-gradient-position: initial;--tw-gradient-from: #0000;--tw-gradient-via: #0000;--tw-gradient-to: #0000;--tw-gradient-stops: initial;--tw-gradient-via-stops: initial;--tw-gradient-from-position: 0%;--tw-gradient-via-position: 50%;--tw-gradient-to-position: 100%;--tw-leading: initial;--tw-font-weight: initial;--tw-tracking: initial;--tw-ordinal: initial;--tw-slashed-zero: initial;--tw-numeric-figure: initial;--tw-numeric-spacing: initial;--tw-numeric-fraction: initial;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000;--tw-outline-style: solid;--tw-backdrop-blur: initial;--tw-backdrop-brightness: initial;--tw-backdrop-contrast: initial;--tw-backdrop-grayscale: initial;--tw-backdrop-hue-rotate: initial;--tw-backdrop-invert: initial;--tw-backdrop-opacity: initial;--tw-backdrop-saturate: initial;--tw-backdrop-sepia: initial;--tw-duration: initial;--tw-ease: initial;--tw-scale-x: 1;--tw-scale-y: 1;--tw-scale-z: 1}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100: oklch(.936 .032 17.717);--color-red-300: oklch(.808 .114 19.571);--color-red-400: oklch(.704 .191 22.216);--color-red-500: oklch(.637 .237 25.331);--color-red-600: oklch(.577 .245 27.325);--color-red-700: oklch(.505 .213 27.518);--color-red-900: oklch(.396 .141 25.723);--color-orange-50: oklch(.98 .016 73.684);--color-orange-200: oklch(.901 .076 70.697);--color-orange-500: oklch(.705 .213 47.604);--color-orange-700: oklch(.553 .195 38.402);--color-yellow-400: oklch(.852 .199 91.936);--color-yellow-500: oklch(.795 .184 86.047);--color-green-100: oklch(.962 .044 156.743);--color-green-500: oklch(.723 .219 149.579);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-emerald-50: oklch(.979 .021 166.113);--color-emerald-600: oklch(.596 .145 163.225);--color-blue-50: oklch(.97 .014 254.604);--color-blue-100: oklch(.932 .032 255.585);--color-blue-200: oklch(.882 .059 254.128);--color-blue-300: oklch(.809 .105 251.813);--color-blue-400: oklch(.707 .165 254.624);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-blue-800: oklch(.424 .199 265.638);--color-blue-900: oklch(.379 .146 265.522);--color-indigo-50: oklch(.962 .018 272.314);--color-indigo-600: oklch(.511 .262 276.966);--color-gray-50: oklch(.985 .002 247.839);--color-gray-100: oklch(.967 .003 264.542);--color-gray-200: oklch(.928 .006 264.531);--color-gray-300: oklch(.872 .01 258.338);--color-gray-400: oklch(.707 .022 261.325);--color-gray-500: oklch(.551 .027 264.364);--color-gray-600: oklch(.446 .03 256.802);--color-gray-700: oklch(.373 .034 259.733);--color-gray-800: oklch(.278 .033 256.848);--color-gray-900: oklch(.21 .034 264.665);--color-black: #000;--color-white: #fff;--spacing: .25rem;--container-xs: 20rem;--container-md: 28rem;--container-lg: 32rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-base--line-height: 1.5 ;--text-lg: 1.125rem;--text-lg--line-height: calc(1.75 / 1.125);--text-xl: 1.25rem;--text-xl--line-height: calc(1.75 / 1.25);--text-2xl: 1.5rem;--text-2xl--line-height: calc(2 / 1.5);--text-3xl: 1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semibold: 600;--font-weight-bold: 700;--tracking-wide: .025em;--tracking-wider: .05em;--leading-tight: 1.25;--leading-relaxed: 1.625;--radius-xs: .125rem;--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.\@container\/card-header{container:card-header / inline-size}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-4{top:calc(var(--spacing) * 4)}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-12{bottom:calc(var(--spacing) * 12)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2 / span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-auto{margin-block:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-square{aspect-ratio:1}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-32{height:calc(var(--spacing) * 32)}.h-64{height:calc(var(--spacing) * 64)}.h-\[1\.15rem\]{height:1.15rem}.h-\[120px\]{height:120px}.h-\[200px\]{height:200px}.h-\[280px\]{height:280px}.h-\[300px\]{height:300px}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-\[90vh\]{max-height:90vh}.min-h-\[400px\]{min-height:400px}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-\[100px\]{width:100px}.w-\[120px\]{width:120px}.w-\[140px\]{width:140px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[180px\]{width:180px}.w-\[200px\]{width:200px}.w-\[250px\]{width:250px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-\[200px\]{max-width:200px}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.translate-x-\[-50\%\]{--tw-translate-x: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.-space-x-\[1px\]>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(-1px * var(--tw-space-x-reverse));margin-inline-end:calc(-1px * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style: dashed;border-style:dashed}.border-none{--tw-border-style: none;border-style:none}.border-black{border-color:var(--color-black)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-800{border-color:var(--color-blue-800)}.border-blue-800\/50{border-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.border-blue-800\/50{border-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-800{border-color:var(--color-gray-800)}.border-input{border-color:var(--input)}.border-orange-200{border-color:var(--color-orange-200)}.border-red-600{border-color:var(--color-red-600)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#1e3a8a\]{background-color:#1e3a8a}.bg-\[\#2c7bb6\]{background-color:#2c7bb6}.bg-\[\#4CAF50\]{background-color:#4caf50}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/40{background-color:#0006}@supports (color: color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color: color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-blue-800{background-color:var(--color-blue-800)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:color-mix(in srgb,oklch(.985 .002 247.839) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-50\/50{background-color:color-mix(in oklab,var(--color-gray-50) 50%,transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:color-mix(in srgb,oklch(.928 .006 264.531) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-200\/50{background-color:color-mix(in oklab,var(--color-gray-200) 50%,transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-input-background{background-color:var(--input-background)}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-gradient-to-b{--tw-gradient-position: to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-gray-50{--tw-gradient-from: var(--color-gray-50);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-100{--tw-gradient-to: var(--color-gray-100);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[3px\]{padding:3px}.p-px{padding:1px}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading, var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading, var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading, var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading, var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading: 1;line-height:1}.leading-relaxed{--tw-leading: var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading: var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight: var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight: var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight: var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight: var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking: var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking: var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-\[\#2c7bb6\]{color:#2c7bb6}.text-black{color:var(--color-black)}.text-blue-100{color:var(--color-blue-100)}.text-blue-200{color:var(--color-blue-200)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-muted-foreground{color:var(--muted-foreground)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, )}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, #00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, #0000001a), 0 4px 6px -4px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, #0000001a), 0 8px 10px -6px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-blue-900\/20{--tw-shadow-color: color-mix(in srgb, oklch(.379 .146 265.522) 20%, transparent)}@supports (color: color-mix(in lab,red,red)){.shadow-blue-900\/20{--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-900) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-offset-background{--tw-ring-offset-color: var(--background)}.outline-hidden{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-\[1px\]{--tw-backdrop-blur: blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, )}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration: .2s;transition-duration:.2s}.duration-1000{--tw-duration: 1s;transition-duration:1s}.ease-linear{--tw-ease: linear;transition-timing-function:linear}.outline-none{--tw-outline-style: none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.selection\:bg-primary ::selection,.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection,.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:pb-0:last-child{padding-bottom:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x: 110%;--tw-scale-y: 110%;--tw-scale-z: 110%;scale:var(--tw-scale-x) var(--tw-scale-y)}}@media(hover:hover){.hover\:bg-\[\#43a047\]:hover{background-color:#43a047}}@media(hover:hover){.hover\:bg-\[\#256b9e\]:hover{background-color:#256b9e}}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}}@media(hover:hover){.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}}@media(hover:hover){.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}}@media(hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\:bg-blue-800:hover{background-color:var(--color-blue-800)}}@media(hover:hover){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 30%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in oklab,var(--color-blue-800) 30%,transparent)}}}@media(hover:hover){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}}@media(hover:hover){.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}@media(hover:hover){.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}}@media(hover:hover){.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}}@media(hover:hover){.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){.hover\:bg-red-900\/20:hover{background-color:color-mix(in srgb,oklch(.396 .141 25.723) 20%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-red-900\/20:hover{background-color:color-mix(in oklab,var(--color-red-900) 20%,transparent)}}}@media(hover:hover){.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}}@media(hover:hover){.hover\:bg-yellow-500:hover{background-color:var(--color-yellow-500)}}@media(hover:hover){.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}@media(hover:hover){.hover\:text-gray-600:hover{color:var(--color-gray-600)}}@media(hover:hover){.hover\:text-gray-700:hover{color:var(--color-gray-700)}}@media(hover:hover){.hover\:text-red-600:hover{color:var(--color-red-600)}}@media(hover:hover){.hover\:text-white:hover{color:var(--color-white)}}@media(hover:hover){.hover\:underline:hover{text-decoration-line:underline}}@media(hover:hover){.hover\:opacity-100:hover{opacity:1}}@media(hover:hover){.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:bg-white:focus{background-color:var(--color-white)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\:ring-ring:focus{--tw-ring-color: var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px;--tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: var(--ring)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y: calc(2 * var(--spacing) * -1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x: calc(2 * var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x: calc(2 * var(--spacing) * -1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y: calc(2 * var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:var(--card)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale: .95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale: .95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-switch-background[data-state=unchecked]{background-color:var(--switch-background)}@media(width>=40rem){.sm\:ml-0{margin-left:calc(var(--spacing) * 0)}}@media(width>=40rem){.sm\:w-auto{width:auto}}@media(width>=40rem){.sm\:max-w-\[500px\]{max-width:500px}}@media(width>=40rem){.sm\:max-w-\[600px\]{max-width:600px}}@media(width>=40rem){.sm\:max-w-lg{max-width:var(--container-lg)}}@media(width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=40rem){.sm\:flex-row{flex-direction:row}}@media(width>=40rem){.sm\:items-center{align-items:center}}@media(width>=40rem){.sm\:justify-end{justify-content:flex-end}}@media(width>=40rem){.sm\:text-left{text-align:left}}@media(width>=48rem){.md\:block{display:block}}@media(width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=48rem){.md\:flex-row{flex-direction:row}}@media(width>=48rem){.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}}@media(width>=48rem){.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}}@media(width>=64rem){.lg\:col-span-2{grid-column:span 2 / span 2}}@media(width>=64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:text-muted-foreground:is(.dark *){color:var(--muted-foreground)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}}@media(hover:hover){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:var(--input)}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:var(--foreground)}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.dark\:data-\[state\=unchecked\]\:bg-card-foreground:is(.dark *)[data-state=unchecked]{background-color:var(--card-foreground)}.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:color-mix(in oklab,var(--input) 80%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing) * 0)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}.\[\&\:last-child\]\:pb-6:last-child{padding-bottom:calc(var(--spacing) * 6)}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y: 2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}:root{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size)}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-space-x-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-gradient-position{syntax: "*"; inherits: false}@property --tw-gradient-from{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-via{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-to{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-stops{syntax: "*"; inherits: false}@property --tw-gradient-via-stops{syntax: "*"; inherits: false}@property --tw-gradient-from-position{syntax: "<length-percentage>"; inherits: false; initial-value: 0%;}@property --tw-gradient-via-position{syntax: "<length-percentage>"; inherits: false; initial-value: 50%;}@property --tw-gradient-to-position{syntax: "<length-percentage>"; inherits: false; initial-value: 100%;}@property --tw-leading{syntax: "*"; inherits: false}@property --tw-font-weight{syntax: "*"; inherits: false}@property --tw-tracking{syntax: "*"; inherits: false}@property --tw-ordinal{syntax: "*"; inherits: false}@property --tw-slashed-zero{syntax: "*"; inherits: false}@property --tw-numeric-figure{syntax: "*"; inherits: false}@property --tw-numeric-spacing{syntax: "*"; inherits: false}@property --tw-numeric-fraction{syntax: "*"; inherits: false}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: "<length>"; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-outline-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-backdrop-blur{syntax: "*"; inherits: false}@property --tw-backdrop-brightness{syntax: "*"; inherits: false}@property --tw-backdrop-contrast{syntax: "*"; inherits: false}@property --tw-backdrop-grayscale{syntax: "*"; inherits: false}@property --tw-backdrop-hue-rotate{syntax: "*"; inherits: false}@property --tw-backdrop-invert{syntax: "*"; inherits: false}@property --tw-backdrop-opacity{syntax: "*"; inherits: false}@property --tw-backdrop-saturate{syntax: "*"; inherits: false}@property --tw-backdrop-sepia{syntax: "*"; inherits: false}@property --tw-duration{syntax: "*"; inherits: false}@property --tw-ease{syntax: "*"; inherits: false}@property --tw-scale-x{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-y{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-z{syntax: "*"; inherits: false; initial-value: 1;}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}[data-sonner-toaster]{z-index:10000!important}@font-face{font-family:FreightSans Bold;src:url(/assets/FreightSans%20Bold-CftzBXfG.ttf) format("truetype");font-weight:700;font-style:normal;font-display:swap;unicode-range:U+0000-002F,U+003A-10FFFF}:root{--font-sans: "FreightSans Bold", ui-sans-serif, system-ui, sans-serif;--font-numeric: ui-sans-serif, system-ui, sans-serif}body{font-family:var(--font-sans)}.font-numeric{font-family:var(--font-numeric)!important} |
美国版/Food Labeling Management Platform/build/assets/index-Dc47WtG1.css deleted
| 1 | -/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-space-x-reverse: 0;--tw-border-style: solid;--tw-gradient-position: initial;--tw-gradient-from: #0000;--tw-gradient-via: #0000;--tw-gradient-to: #0000;--tw-gradient-stops: initial;--tw-gradient-via-stops: initial;--tw-gradient-from-position: 0%;--tw-gradient-via-position: 50%;--tw-gradient-to-position: 100%;--tw-leading: initial;--tw-font-weight: initial;--tw-tracking: initial;--tw-ordinal: initial;--tw-slashed-zero: initial;--tw-numeric-figure: initial;--tw-numeric-spacing: initial;--tw-numeric-fraction: initial;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000;--tw-outline-style: solid;--tw-backdrop-blur: initial;--tw-backdrop-brightness: initial;--tw-backdrop-contrast: initial;--tw-backdrop-grayscale: initial;--tw-backdrop-hue-rotate: initial;--tw-backdrop-invert: initial;--tw-backdrop-opacity: initial;--tw-backdrop-saturate: initial;--tw-backdrop-sepia: initial;--tw-duration: initial;--tw-ease: initial;--tw-scale-x: 1;--tw-scale-y: 1;--tw-scale-z: 1}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100: oklch(.936 .032 17.717);--color-red-300: oklch(.808 .114 19.571);--color-red-400: oklch(.704 .191 22.216);--color-red-500: oklch(.637 .237 25.331);--color-red-600: oklch(.577 .245 27.325);--color-red-700: oklch(.505 .213 27.518);--color-red-900: oklch(.396 .141 25.723);--color-orange-50: oklch(.98 .016 73.684);--color-orange-200: oklch(.901 .076 70.697);--color-orange-500: oklch(.705 .213 47.604);--color-orange-700: oklch(.553 .195 38.402);--color-yellow-400: oklch(.852 .199 91.936);--color-yellow-500: oklch(.795 .184 86.047);--color-green-100: oklch(.962 .044 156.743);--color-green-500: oklch(.723 .219 149.579);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-emerald-50: oklch(.979 .021 166.113);--color-emerald-600: oklch(.596 .145 163.225);--color-blue-50: oklch(.97 .014 254.604);--color-blue-100: oklch(.932 .032 255.585);--color-blue-200: oklch(.882 .059 254.128);--color-blue-300: oklch(.809 .105 251.813);--color-blue-400: oklch(.707 .165 254.624);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-blue-800: oklch(.424 .199 265.638);--color-blue-900: oklch(.379 .146 265.522);--color-indigo-50: oklch(.962 .018 272.314);--color-indigo-600: oklch(.511 .262 276.966);--color-gray-50: oklch(.985 .002 247.839);--color-gray-100: oklch(.967 .003 264.542);--color-gray-200: oklch(.928 .006 264.531);--color-gray-300: oklch(.872 .01 258.338);--color-gray-400: oklch(.707 .022 261.325);--color-gray-500: oklch(.551 .027 264.364);--color-gray-600: oklch(.446 .03 256.802);--color-gray-700: oklch(.373 .034 259.733);--color-gray-800: oklch(.278 .033 256.848);--color-gray-900: oklch(.21 .034 264.665);--color-black: #000;--color-white: #fff;--spacing: .25rem;--container-xs: 20rem;--container-md: 28rem;--container-lg: 32rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-base--line-height: 1.5 ;--text-lg: 1.125rem;--text-lg--line-height: calc(1.75 / 1.125);--text-xl: 1.25rem;--text-xl--line-height: calc(1.75 / 1.25);--text-2xl: 1.5rem;--text-2xl--line-height: calc(2 / 1.5);--text-3xl: 1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semibold: 600;--font-weight-bold: 700;--tracking-wide: .025em;--tracking-wider: .05em;--leading-tight: 1.25;--leading-relaxed: 1.625;--radius-xs: .125rem;--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.\@container\/card-header{container:card-header / inline-size}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-4{top:calc(var(--spacing) * 4)}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-12{bottom:calc(var(--spacing) * 12)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2 / span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-auto{margin-block:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-square{aspect-ratio:1}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-32{height:calc(var(--spacing) * 32)}.h-64{height:calc(var(--spacing) * 64)}.h-\[1\.15rem\]{height:1.15rem}.h-\[120px\]{height:120px}.h-\[200px\]{height:200px}.h-\[280px\]{height:280px}.h-\[300px\]{height:300px}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-\[90vh\]{max-height:90vh}.min-h-\[400px\]{min-height:400px}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-\[100px\]{width:100px}.w-\[120px\]{width:120px}.w-\[140px\]{width:140px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[180px\]{width:180px}.w-\[200px\]{width:200px}.w-\[250px\]{width:250px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-\[200px\]{max-width:200px}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.translate-x-\[-50\%\]{--tw-translate-x: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.-space-x-\[1px\]>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(-1px * var(--tw-space-x-reverse));margin-inline-end:calc(-1px * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style: dashed;border-style:dashed}.border-none{--tw-border-style: none;border-style:none}.border-black{border-color:var(--color-black)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-800{border-color:var(--color-blue-800)}.border-blue-800\/50{border-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.border-blue-800\/50{border-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-800{border-color:var(--color-gray-800)}.border-input{border-color:var(--input)}.border-orange-200{border-color:var(--color-orange-200)}.border-red-600{border-color:var(--color-red-600)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#1e3a8a\]{background-color:#1e3a8a}.bg-\[\#2c7bb6\]{background-color:#2c7bb6}.bg-\[\#4CAF50\]{background-color:#4caf50}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/40{background-color:#0006}@supports (color: color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color: color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-blue-800{background-color:var(--color-blue-800)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:color-mix(in srgb,oklch(.985 .002 247.839) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-50\/50{background-color:color-mix(in oklab,var(--color-gray-50) 50%,transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:color-mix(in srgb,oklch(.928 .006 264.531) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-200\/50{background-color:color-mix(in oklab,var(--color-gray-200) 50%,transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-input-background{background-color:var(--input-background)}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-gradient-to-b{--tw-gradient-position: to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-gray-50{--tw-gradient-from: var(--color-gray-50);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-100{--tw-gradient-to: var(--color-gray-100);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[3px\]{padding:3px}.p-px{padding:1px}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading, var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading, var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading, var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading, var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading: 1;line-height:1}.leading-relaxed{--tw-leading: var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading: var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight: var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight: var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight: var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight: var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking: var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking: var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-\[\#2c7bb6\]{color:#2c7bb6}.text-black{color:var(--color-black)}.text-blue-100{color:var(--color-blue-100)}.text-blue-200{color:var(--color-blue-200)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-muted-foreground{color:var(--muted-foreground)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, )}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, #00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, #0000001a), 0 4px 6px -4px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, #0000001a), 0 8px 10px -6px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-blue-900\/20{--tw-shadow-color: color-mix(in srgb, oklch(.379 .146 265.522) 20%, transparent)}@supports (color: color-mix(in lab,red,red)){.shadow-blue-900\/20{--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-900) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-offset-background{--tw-ring-offset-color: var(--background)}.outline-hidden{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-\[1px\]{--tw-backdrop-blur: blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, )}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration: .2s;transition-duration:.2s}.duration-1000{--tw-duration: 1s;transition-duration:1s}.ease-linear{--tw-ease: linear;transition-timing-function:linear}.outline-none{--tw-outline-style: none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.selection\:bg-primary ::selection,.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection,.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:pb-0:last-child{padding-bottom:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x: 110%;--tw-scale-y: 110%;--tw-scale-z: 110%;scale:var(--tw-scale-x) var(--tw-scale-y)}}@media(hover:hover){.hover\:bg-\[\#43a047\]:hover{background-color:#43a047}}@media(hover:hover){.hover\:bg-\[\#256b9e\]:hover{background-color:#256b9e}}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}}@media(hover:hover){.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}}@media(hover:hover){.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}}@media(hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\:bg-blue-800:hover{background-color:var(--color-blue-800)}}@media(hover:hover){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 30%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in oklab,var(--color-blue-800) 30%,transparent)}}}@media(hover:hover){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}}@media(hover:hover){.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}@media(hover:hover){.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}}@media(hover:hover){.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}}@media(hover:hover){.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){.hover\:bg-red-900\/20:hover{background-color:color-mix(in srgb,oklch(.396 .141 25.723) 20%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-red-900\/20:hover{background-color:color-mix(in oklab,var(--color-red-900) 20%,transparent)}}}@media(hover:hover){.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}}@media(hover:hover){.hover\:bg-yellow-500:hover{background-color:var(--color-yellow-500)}}@media(hover:hover){.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}@media(hover:hover){.hover\:text-gray-600:hover{color:var(--color-gray-600)}}@media(hover:hover){.hover\:text-gray-700:hover{color:var(--color-gray-700)}}@media(hover:hover){.hover\:text-red-600:hover{color:var(--color-red-600)}}@media(hover:hover){.hover\:text-white:hover{color:var(--color-white)}}@media(hover:hover){.hover\:underline:hover{text-decoration-line:underline}}@media(hover:hover){.hover\:opacity-100:hover{opacity:1}}@media(hover:hover){.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:bg-white:focus{background-color:var(--color-white)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\:ring-ring:focus{--tw-ring-color: var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px;--tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: var(--ring)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y: calc(2 * var(--spacing) * -1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x: calc(2 * var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x: calc(2 * var(--spacing) * -1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y: calc(2 * var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:var(--card)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale: .95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale: .95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-switch-background[data-state=unchecked]{background-color:var(--switch-background)}@media(width>=40rem){.sm\:ml-0{margin-left:calc(var(--spacing) * 0)}}@media(width>=40rem){.sm\:w-auto{width:auto}}@media(width>=40rem){.sm\:max-w-\[500px\]{max-width:500px}}@media(width>=40rem){.sm\:max-w-\[600px\]{max-width:600px}}@media(width>=40rem){.sm\:max-w-lg{max-width:var(--container-lg)}}@media(width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=40rem){.sm\:flex-row{flex-direction:row}}@media(width>=40rem){.sm\:items-center{align-items:center}}@media(width>=40rem){.sm\:justify-end{justify-content:flex-end}}@media(width>=40rem){.sm\:text-left{text-align:left}}@media(width>=48rem){.md\:block{display:block}}@media(width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=48rem){.md\:flex-row{flex-direction:row}}@media(width>=48rem){.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}}@media(width>=48rem){.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}}@media(width>=64rem){.lg\:col-span-2{grid-column:span 2 / span 2}}@media(width>=64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:text-muted-foreground:is(.dark *){color:var(--muted-foreground)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}}@media(hover:hover){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:var(--input)}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:var(--foreground)}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.dark\:data-\[state\=unchecked\]\:bg-card-foreground:is(.dark *)[data-state=unchecked]{background-color:var(--card-foreground)}.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:color-mix(in oklab,var(--input) 80%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing) * 0)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}.\[\&\:last-child\]\:pb-6:last-child{padding-bottom:calc(var(--spacing) * 6)}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y: 2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}:root{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size)}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-space-x-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-gradient-position{syntax: "*"; inherits: false}@property --tw-gradient-from{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-via{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-to{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-stops{syntax: "*"; inherits: false}@property --tw-gradient-via-stops{syntax: "*"; inherits: false}@property --tw-gradient-from-position{syntax: "<length-percentage>"; inherits: false; initial-value: 0%;}@property --tw-gradient-via-position{syntax: "<length-percentage>"; inherits: false; initial-value: 50%;}@property --tw-gradient-to-position{syntax: "<length-percentage>"; inherits: false; initial-value: 100%;}@property --tw-leading{syntax: "*"; inherits: false}@property --tw-font-weight{syntax: "*"; inherits: false}@property --tw-tracking{syntax: "*"; inherits: false}@property --tw-ordinal{syntax: "*"; inherits: false}@property --tw-slashed-zero{syntax: "*"; inherits: false}@property --tw-numeric-figure{syntax: "*"; inherits: false}@property --tw-numeric-spacing{syntax: "*"; inherits: false}@property --tw-numeric-fraction{syntax: "*"; inherits: false}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: "<length>"; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-outline-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-backdrop-blur{syntax: "*"; inherits: false}@property --tw-backdrop-brightness{syntax: "*"; inherits: false}@property --tw-backdrop-contrast{syntax: "*"; inherits: false}@property --tw-backdrop-grayscale{syntax: "*"; inherits: false}@property --tw-backdrop-hue-rotate{syntax: "*"; inherits: false}@property --tw-backdrop-invert{syntax: "*"; inherits: false}@property --tw-backdrop-opacity{syntax: "*"; inherits: false}@property --tw-backdrop-saturate{syntax: "*"; inherits: false}@property --tw-backdrop-sepia{syntax: "*"; inherits: false}@property --tw-duration{syntax: "*"; inherits: false}@property --tw-ease{syntax: "*"; inherits: false}@property --tw-scale-x{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-y{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-z{syntax: "*"; inherits: false; initial-value: 1;}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}[data-sonner-toaster]{z-index:10000!important}@font-face{font-family:FreightSans Bold;src:url(/assets/FreightSans%20Bold-CftzBXfG.ttf) format("truetype");font-weight:700;font-style:normal;font-display:swap;unicode-range:U+0000-002F,U+003A-10FFFF}:root{--font-sans: "FreightSans Bold", ui-sans-serif, system-ui, sans-serif;--font-numeric: ui-sans-serif, system-ui, sans-serif}body{font-family:var(--font-sans)}.font-numeric{font-family:var(--font-numeric)!important} |
美国版/Food Labeling Management Platform/build/index.html
| @@ -5,8 +5,8 @@ | @@ -5,8 +5,8 @@ | ||
| 5 | <meta charset="UTF-8" /> | 5 | <meta charset="UTF-8" /> |
| 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 7 | <title>Food Labeling Management Platform</title> | 7 | <title>Food Labeling Management Platform</title> |
| 8 | - <script type="module" crossorigin src="/assets/index-BHd3BZos.js"></script> | ||
| 9 | - <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css"> | 8 | + <script type="module" crossorigin src="/assets/index-ChVLtgeV.js"></script> |
| 9 | + <link rel="stylesheet" crossorigin href="/assets/index-DLL5VTnd.css"> | ||
| 10 | </head> | 10 | </head> |
| 11 | 11 | ||
| 12 | <body> | 12 | <body> |
美国版/Food Labeling Management Platform/src/App.tsx
| @@ -33,6 +33,8 @@ function AuthedApp() { | @@ -33,6 +33,8 @@ function AuthedApp() { | ||
| 33 | /** Dashboard「View Reports」:与 reportsTargetTab 配合,仅在意图跳转时递增 */ | 33 | /** Dashboard「View Reports」:与 reportsTargetTab 配合,仅在意图跳转时递增 */ |
| 34 | const [reportsOpenKey, setReportsOpenKey] = useState(0); | 34 | const [reportsOpenKey, setReportsOpenKey] = useState(0); |
| 35 | const [reportsTargetTab, setReportsTargetTab] = useState<'print-log' | 'label-report'>('print-log'); | 35 | const [reportsTargetTab, setReportsTargetTab] = useState<'print-log' | 'label-report'>('print-log'); |
| 36 | + /** 标签模板编辑器全屏:Layout 隐藏侧栏/顶栏 */ | ||
| 37 | + const [labelTemplateEditorFullscreen, setLabelTemplateEditorFullscreen] = useState(false); | ||
| 36 | 38 | ||
| 37 | const resolveView = (name: string) => { | 39 | const resolveView = (name: string) => { |
| 38 | const s = (name ?? "").trim(); | 40 | const s = (name ?? "").trim(); |
| @@ -67,6 +69,9 @@ function AuthedApp() { | @@ -67,6 +69,9 @@ function AuthedApp() { | ||
| 67 | if (resolvedView !== 'Reports') { | 69 | if (resolvedView !== 'Reports') { |
| 68 | setReportsOpenKey(0); | 70 | setReportsOpenKey(0); |
| 69 | } | 71 | } |
| 72 | + if (resolvedView !== 'Label Templates') { | ||
| 73 | + setLabelTemplateEditorFullscreen(false); | ||
| 74 | + } | ||
| 70 | }, [resolvedView]); | 75 | }, [resolvedView]); |
| 71 | 76 | ||
| 72 | if (!auth.token) { | 77 | if (!auth.token) { |
| @@ -128,6 +133,7 @@ function AuthedApp() { | @@ -128,6 +133,7 @@ function AuthedApp() { | ||
| 128 | onViewChange={setCurrentView} | 133 | onViewChange={setCurrentView} |
| 129 | labelCreateOpenSeq={labelCreateOpenSeq} | 134 | labelCreateOpenSeq={labelCreateOpenSeq} |
| 130 | onLabelCreateIntentConsumed={consumeLabelCreateIntent} | 135 | onLabelCreateIntentConsumed={consumeLabelCreateIntent} |
| 136 | + onLabelTemplateEditorLayoutOverlay={setLabelTemplateEditorFullscreen} | ||
| 131 | /> | 137 | /> |
| 132 | ); | 138 | ); |
| 133 | default: | 139 | default: |
| @@ -137,7 +143,13 @@ function AuthedApp() { | @@ -137,7 +143,13 @@ function AuthedApp() { | ||
| 137 | 143 | ||
| 138 | return ( | 144 | return ( |
| 139 | <> | 145 | <> |
| 140 | - <Layout currentView={resolvedView} setCurrentView={navigateToView} menus={auth.menus} onLogout={auth.logout}> | 146 | + <Layout |
| 147 | + currentView={resolvedView} | ||
| 148 | + setCurrentView={navigateToView} | ||
| 149 | + menus={auth.menus} | ||
| 150 | + onLogout={auth.logout} | ||
| 151 | + hideAppChrome={labelTemplateEditorFullscreen} | ||
| 152 | + > | ||
| 141 | {renderView()} | 153 | {renderView()} |
| 142 | </Layout> | 154 | </Layout> |
| 143 | </> | 155 | </> |
美国版/Food Labeling Management Platform/src/components/bulk/batch-import-dialog.tsx
0 → 100644
| 1 | +import React, { useRef, useState } from "react"; | ||
| 2 | +import { Button } from "../ui/button"; | ||
| 3 | +import { | ||
| 4 | + Dialog, | ||
| 5 | + DialogContent, | ||
| 6 | + DialogDescription, | ||
| 7 | + DialogFooter, | ||
| 8 | + DialogHeader, | ||
| 9 | + DialogTitle, | ||
| 10 | +} from "../ui/dialog"; | ||
| 11 | +import { Label } from "../ui/label"; | ||
| 12 | +import { toast } from "sonner"; | ||
| 13 | +import { ApiError } from "../../lib/apiClient"; | ||
| 14 | + | ||
| 15 | +export type BatchImportDialogProps = { | ||
| 16 | + open: boolean; | ||
| 17 | + onOpenChange: (open: boolean) => void; | ||
| 18 | + title: string; | ||
| 19 | + description?: string; | ||
| 20 | + /** 弹框底部「下载模板」 */ | ||
| 21 | + onDownloadTemplate: () => void | Promise<void>; | ||
| 22 | + /** 选择文件后点击 Import 上传 */ | ||
| 23 | + onImportFile: (file: File) => Promise<{ successCount: number; failCount: number }>; | ||
| 24 | + downloadingTemplate?: boolean; | ||
| 25 | +}; | ||
| 26 | + | ||
| 27 | +export function BatchImportDialog({ | ||
| 28 | + open, | ||
| 29 | + onOpenChange, | ||
| 30 | + title, | ||
| 31 | + description, | ||
| 32 | + onDownloadTemplate, | ||
| 33 | + onImportFile, | ||
| 34 | + downloadingTemplate = false, | ||
| 35 | +}: BatchImportDialogProps) { | ||
| 36 | + const inputRef = useRef<HTMLInputElement | null>(null); | ||
| 37 | + const [file, setFile] = useState<File | null>(null); | ||
| 38 | + const [busy, setBusy] = useState(false); | ||
| 39 | + | ||
| 40 | + const reset = () => { | ||
| 41 | + setFile(null); | ||
| 42 | + if (inputRef.current) inputRef.current.value = ""; | ||
| 43 | + }; | ||
| 44 | + | ||
| 45 | + return ( | ||
| 46 | + <Dialog | ||
| 47 | + open={open} | ||
| 48 | + onOpenChange={(v) => { | ||
| 49 | + if (!v) reset(); | ||
| 50 | + onOpenChange(v); | ||
| 51 | + }} | ||
| 52 | + > | ||
| 53 | + <DialogContent className="sm:max-w-md"> | ||
| 54 | + <DialogHeader> | ||
| 55 | + <DialogTitle>{title}</DialogTitle> | ||
| 56 | + {description ? <DialogDescription>{description}</DialogDescription> : null} | ||
| 57 | + </DialogHeader> | ||
| 58 | + <div className="flex flex-col gap-4 py-2"> | ||
| 59 | + <div className="space-y-2"> | ||
| 60 | + <Label htmlFor="batch-import-file">Excel file (.xlsx)</Label> | ||
| 61 | + <input | ||
| 62 | + id="batch-import-file" | ||
| 63 | + ref={inputRef} | ||
| 64 | + type="file" | ||
| 65 | + accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | ||
| 66 | + className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border file:border-gray-300 file:bg-white file:px-3 file:py-2 file:text-sm file:font-medium file:text-gray-900 hover:file:bg-gray-50" | ||
| 67 | + onChange={(e) => { | ||
| 68 | + const f = e.target.files?.[0] ?? null; | ||
| 69 | + setFile(f); | ||
| 70 | + }} | ||
| 71 | + /> | ||
| 72 | + </div> | ||
| 73 | + </div> | ||
| 74 | + <div className="flex justify-center pb-2"> | ||
| 75 | + <Button | ||
| 76 | + type="button" | ||
| 77 | + variant="outline" | ||
| 78 | + className="w-full sm:w-auto" | ||
| 79 | + disabled={downloadingTemplate} | ||
| 80 | + onClick={() => void onDownloadTemplate()} | ||
| 81 | + > | ||
| 82 | + {downloadingTemplate ? "Downloading…" : "Download template"} | ||
| 83 | + </Button> | ||
| 84 | + </div> | ||
| 85 | + <DialogFooter className="gap-2 sm:gap-0"> | ||
| 86 | + <Button | ||
| 87 | + type="button" | ||
| 88 | + variant="outline" | ||
| 89 | + onClick={() => { | ||
| 90 | + reset(); | ||
| 91 | + onOpenChange(false); | ||
| 92 | + }} | ||
| 93 | + > | ||
| 94 | + Cancel | ||
| 95 | + </Button> | ||
| 96 | + <Button | ||
| 97 | + type="button" | ||
| 98 | + disabled={!file || busy} | ||
| 99 | + onClick={async () => { | ||
| 100 | + if (!file) return; | ||
| 101 | + setBusy(true); | ||
| 102 | + try { | ||
| 103 | + const r = await onImportFile(file); | ||
| 104 | + toast.success("Import finished", { | ||
| 105 | + description: `Success: ${r.successCount}, failed: ${r.failCount}`, | ||
| 106 | + }); | ||
| 107 | + reset(); | ||
| 108 | + onOpenChange(false); | ||
| 109 | + } catch (e) { | ||
| 110 | + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Import failed."; | ||
| 111 | + toast.error("Import failed", { description: msg }); | ||
| 112 | + } finally { | ||
| 113 | + setBusy(false); | ||
| 114 | + } | ||
| 115 | + }} | ||
| 116 | + > | ||
| 117 | + {busy ? "Importing…" : "Import"} | ||
| 118 | + </Button> | ||
| 119 | + </DialogFooter> | ||
| 120 | + </DialogContent> | ||
| 121 | + </Dialog> | ||
| 122 | + ); | ||
| 123 | +} |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx
| @@ -40,6 +40,13 @@ import { | @@ -40,6 +40,13 @@ import { | ||
| 40 | serializePrintInputOffset, | 40 | serializePrintInputOffset, |
| 41 | tryParsePrintInputOffsetStored, | 41 | tryParsePrintInputOffsetStored, |
| 42 | } from '../../lib/labelFormDatePreview'; | 42 | } from '../../lib/labelFormDatePreview'; |
| 43 | +import { | ||
| 44 | + foldNutritionCompositeKeysIntoDefaults, | ||
| 45 | + hydrateRowFieldValuesWithNutritionColumns, | ||
| 46 | + listNutritionManualFieldSpecs, | ||
| 47 | + nutritionCompositeFieldKey, | ||
| 48 | + type NutritionManualFieldSpec, | ||
| 49 | +} from '../../lib/nutritionManualEntry'; | ||
| 43 | import type { ProductDto } from '../../types/product'; | 50 | import type { ProductDto } from '../../types/product'; |
| 44 | import type { LabelTypeDto } from '../../types/labelType'; | 51 | import type { LabelTypeDto } from '../../types/labelType'; |
| 45 | 52 | ||
| @@ -168,11 +175,31 @@ export function LabelTemplateDataEntryView({ | @@ -168,11 +175,31 @@ export function LabelTemplateDataEntryView({ | ||
| 168 | const [templateTitle, setTemplateTitle] = useState(''); | 175 | const [templateTitle, setTemplateTitle] = useState(''); |
| 169 | /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */ | 176 | /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */ |
| 170 | const [templateDto, setTemplateDto] = useState<LabelTemplateDto | null>(null); | 177 | const [templateDto, setTemplateDto] = useState<LabelTemplateDto | null>(null); |
| 171 | - const [printFields, setPrintFields] = useState<LabelElement[]>([]); | ||
| 172 | const [products, setProducts] = useState<ProductDto[]>([]); | 178 | const [products, setProducts] = useState<ProductDto[]>([]); |
| 173 | const [types, setTypes] = useState<LabelTypeDto[]>([]); | 179 | const [types, setTypes] = useState<LabelTypeDto[]>([]); |
| 174 | const [rows, setRows] = useState<TemplateDataEntryRow[]>([]); | 180 | const [rows, setRows] = useState<TemplateDataEntryRow[]>([]); |
| 175 | 181 | ||
| 182 | + const sortedTemplateElements = useMemo( | ||
| 183 | + () => sortTemplateElementsForDisplay((templateDto?.elements ?? []) as LabelElement[]), | ||
| 184 | + [templateDto], | ||
| 185 | + ); | ||
| 186 | + | ||
| 187 | + const dataColumns = useMemo(() => { | ||
| 188 | + const cols: Array< | ||
| 189 | + | { kind: "element"; el: LabelElement } | ||
| 190 | + | { kind: "nutrition"; parent: LabelElement; spec: NutritionManualFieldSpec } | ||
| 191 | + > = []; | ||
| 192 | + for (const el of sortedTemplateElements) { | ||
| 193 | + if (isDataEntryTableColumnElement(el)) cols.push({ kind: "element", el }); | ||
| 194 | + if (canonicalElementType(el.type) === "NUTRITION") { | ||
| 195 | + for (const spec of listNutritionManualFieldSpecs(el)) { | ||
| 196 | + cols.push({ kind: "nutrition", parent: el, spec }); | ||
| 197 | + } | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + return cols; | ||
| 201 | + }, [sortedTemplateElements]); | ||
| 202 | + | ||
| 176 | const productOptions = useMemo( | 203 | const productOptions = useMemo( |
| 177 | () => | 204 | () => |
| 178 | products.map((p) => { | 205 | products.map((p) => { |
| @@ -209,11 +236,6 @@ export function LabelTemplateDataEntryView({ | @@ -209,11 +236,6 @@ export function LabelTemplateDataEntryView({ | ||
| 209 | (tpl.templateCode ?? tpl.id ?? '').trim() || | 236 | (tpl.templateCode ?? tpl.id ?? '').trim() || |
| 210 | templateCode; | 237 | templateCode; |
| 211 | setTemplateTitle(title); | 238 | setTemplateTitle(title); |
| 212 | - const elements = sortTemplateElementsForDisplay( | ||
| 213 | - (tpl.elements ?? []) as LabelElement[], | ||
| 214 | - ) | ||
| 215 | - .filter(isDataEntryTableColumnElement); | ||
| 216 | - setPrintFields(elements); | ||
| 217 | setProducts(prodRes.items ?? []); | 239 | setProducts(prodRes.items ?? []); |
| 218 | setTypes(typeRes.items ?? []); | 240 | setTypes(typeRes.items ?? []); |
| 219 | setTemplateDto(tpl); | 241 | setTemplateDto(tpl); |
| @@ -230,7 +252,10 @@ export function LabelTemplateDataEntryView({ | @@ -230,7 +252,10 @@ export function LabelTemplateDataEntryView({ | ||
| 230 | id: newRowId(), | 252 | id: newRowId(), |
| 231 | productId: d.productId, | 253 | productId: d.productId, |
| 232 | labelTypeId: d.labelTypeId, | 254 | labelTypeId: d.labelTypeId, |
| 233 | - fieldValues: { ...d.defaultValues }, | 255 | + fieldValues: hydrateRowFieldValuesWithNutritionColumns( |
| 256 | + { ...d.defaultValues }, | ||
| 257 | + (tpl.elements ?? []) as LabelElement[], | ||
| 258 | + ), | ||
| 234 | })), | 259 | })), |
| 235 | ); | 260 | ); |
| 236 | } else { | 261 | } else { |
| @@ -249,7 +274,6 @@ export function LabelTemplateDataEntryView({ | @@ -249,7 +274,6 @@ export function LabelTemplateDataEntryView({ | ||
| 249 | description: e instanceof Error ? e.message : 'Please try again.', | 274 | description: e instanceof Error ? e.message : 'Please try again.', |
| 250 | }); | 275 | }); |
| 251 | setTemplateTitle(templateCode); | 276 | setTemplateTitle(templateCode); |
| 252 | - setPrintFields([]); | ||
| 253 | setRows([]); | 277 | setRows([]); |
| 254 | setTemplateDto(null); | 278 | setTemplateDto(null); |
| 255 | } | 279 | } |
| @@ -312,10 +336,22 @@ export function LabelTemplateDataEntryView({ | @@ -312,10 +336,22 @@ export function LabelTemplateDataEntryView({ | ||
| 312 | } | 336 | } |
| 313 | 337 | ||
| 314 | const validRows = rows.filter((r) => r.productId.trim() && r.labelTypeId.trim()); | 338 | const validRows = rows.filter((r) => r.productId.trim() && r.labelTypeId.trim()); |
| 339 | + const fullElements = sortTemplateElementsForDisplay( | ||
| 340 | + (templateDto.elements ?? []) as LabelElement[], | ||
| 341 | + ); | ||
| 315 | const templateProductDefaults = validRows.map((r, i) => { | 342 | const templateProductDefaults = validRows.map((r, i) => { |
| 343 | + const folded = foldNutritionCompositeKeysIntoDefaults(r.fieldValues, fullElements); | ||
| 316 | const defaultValues: Record<string, string> = {}; | 344 | const defaultValues: Record<string, string> = {}; |
| 317 | - for (const f of printFields) { | ||
| 318 | - defaultValues[f.id] = normalizeDateTimeFieldForSave(f, r.fieldValues[f.id] ?? ''); | 345 | + for (const col of dataColumns) { |
| 346 | + if (col.kind === "element") { | ||
| 347 | + defaultValues[col.el.id] = normalizeDateTimeFieldForSave(col.el, folded[col.el.id] ?? ""); | ||
| 348 | + } | ||
| 349 | + } | ||
| 350 | + for (const el of fullElements) { | ||
| 351 | + if (canonicalElementType(el.type) === "NUTRITION") { | ||
| 352 | + const j = folded[el.id]; | ||
| 353 | + if (j) defaultValues[el.id] = j; | ||
| 354 | + } | ||
| 319 | } | 355 | } |
| 320 | return { | 356 | return { |
| 321 | productId: r.productId.trim(), | 357 | productId: r.productId.trim(), |
| @@ -325,9 +361,6 @@ export function LabelTemplateDataEntryView({ | @@ -325,9 +361,6 @@ export function LabelTemplateDataEntryView({ | ||
| 325 | }; | 361 | }; |
| 326 | }); | 362 | }); |
| 327 | 363 | ||
| 328 | - const fullElements = sortTemplateElementsForDisplay( | ||
| 329 | - (templateDto.elements ?? []) as LabelElement[], | ||
| 330 | - ); | ||
| 331 | if (fullElements.length === 0) { | 364 | if (fullElements.length === 0) { |
| 332 | toast.error('Template has no elements', { description: 'Cannot save this template.' }); | 365 | toast.error('Template has no elements', { description: 'Cannot save this template.' }); |
| 333 | return; | 366 | return; |
| @@ -362,7 +395,7 @@ export function LabelTemplateDataEntryView({ | @@ -362,7 +395,7 @@ export function LabelTemplateDataEntryView({ | ||
| 362 | } finally { | 395 | } finally { |
| 363 | setSaving(false); | 396 | setSaving(false); |
| 364 | } | 397 | } |
| 365 | - }, [templateCode, templateDto, rows, printFields]); | 398 | + }, [templateCode, templateDto, rows, dataColumns]); |
| 366 | 399 | ||
| 367 | return ( | 400 | return ( |
| 368 | <div className="h-full flex flex-col min-h-0"> | 401 | <div className="h-full flex flex-col min-h-0"> |
| @@ -407,23 +440,21 @@ export function LabelTemplateDataEntryView({ | @@ -407,23 +440,21 @@ export function LabelTemplateDataEntryView({ | ||
| 407 | 440 | ||
| 408 | <p className="text-sm text-gray-600 py-3 shrink-0"> | 441 | <p className="text-sm text-gray-600 py-3 shrink-0"> |
| 409 | Bind product and label type per row. Values are saved with the template (edit API) as{' '} | 442 | Bind product and label type per row. Values are saved with the template (edit API) as{' '} |
| 410 | - <span className="font-medium">templateProductDefaults</span> (interface doc section 4.4). Only{' '} | ||
| 411 | - <span className="font-medium">manual input</span> controls appear here ( | ||
| 412 | - <span className="font-medium">PRINT_INPUT</span> and Duration series). Non-manual controls such as{' '} | ||
| 413 | - <span className="font-medium">AUTO_DB / NUTRITION</span> are excluded.{' '} | ||
| 414 | - <span className="font-medium">BARCODE</span> is excluded here and must be generated from print-time | ||
| 415 | - input/data. Date / time / duration columns use <span className="font-medium">unit + value</span>; stored as | ||
| 416 | - JSON with <span className="font-medium">unit</span> and <span className="font-medium">value</span> keys, then | ||
| 417 | - resolved at App print preview using current time and each field's format. Column headers use{' '} | ||
| 418 | - <span className="font-medium">elementName</span>. | 443 | + <span className="font-medium">templateProductDefaults</span> (interface doc section 4.4). Columns cover{' '} |
| 444 | + <span className="font-medium">Label</span> group defaults, <span className="font-medium">PRINT_INPUT</span> / | ||
| 445 | + Duration fields, and (when present) <span className="font-medium">Nutrition Facts</span> manual cells.{' '} | ||
| 446 | + <span className="font-medium">Template</span> panel elements are edited only in the label template editor | ||
| 447 | + (not here). Date / time / duration columns use <span className="font-medium">unit + value</span>; stored as | ||
| 448 | + JSON with <span className="font-medium">unit</span> and <span className="font-medium">value</span> keys. | ||
| 449 | + Nutrition values are stored as JSON under the nutrition element id for App print preview. | ||
| 419 | </p> | 450 | </p> |
| 420 | 451 | ||
| 421 | <div className="flex-1 min-h-0 overflow-auto rounded-md border bg-white shadow-sm"> | 452 | <div className="flex-1 min-h-0 overflow-auto rounded-md border bg-white shadow-sm"> |
| 422 | {loading ? ( | 453 | {loading ? ( |
| 423 | <div className="p-10 text-center text-sm text-gray-500">Loading…</div> | 454 | <div className="p-10 text-center text-sm text-gray-500">Loading…</div> |
| 424 | - ) : printFields.length === 0 ? ( | 455 | + ) : dataColumns.length === 0 ? ( |
| 425 | <div className="p-10 text-center text-sm text-gray-600"> | 456 | <div className="p-10 text-center text-sm text-gray-600"> |
| 426 | - No manual input fields (<span className="font-medium">PRINT_INPUT / Duration series</span>) in this template. | 457 | + No manual input or nutrition columns in this template. |
| 427 | </div> | 458 | </div> |
| 428 | ) : ( | 459 | ) : ( |
| 429 | <Table> | 460 | <Table> |
| @@ -435,13 +466,17 @@ export function LabelTemplateDataEntryView({ | @@ -435,13 +466,17 @@ export function LabelTemplateDataEntryView({ | ||
| 435 | <TableHead className="font-bold text-gray-900 w-[180px] min-w-[140px]"> | 466 | <TableHead className="font-bold text-gray-900 w-[180px] min-w-[140px]"> |
| 436 | Label type | 467 | Label type |
| 437 | </TableHead> | 468 | </TableHead> |
| 438 | - {printFields.map((f) => ( | 469 | + {dataColumns.map((col) => ( |
| 439 | <TableHead | 470 | <TableHead |
| 440 | - key={f.id} | 471 | + key={ |
| 472 | + col.kind === 'element' | ||
| 473 | + ? col.el.id | ||
| 474 | + : nutritionCompositeFieldKey(col.parent.id, col.spec.subKey) | ||
| 475 | + } | ||
| 441 | className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap" | 476 | className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap" |
| 442 | - title={f.id} | 477 | + title={col.kind === 'element' ? col.el.id : `${col.parent.id} · ${col.spec.subKey}`} |
| 443 | > | 478 | > |
| 444 | - {dataEntryColumnLabel(f)} | 479 | + {col.kind === 'element' ? dataEntryColumnLabel(col.el) : col.spec.columnLabel} |
| 445 | </TableHead> | 480 | </TableHead> |
| 446 | ))} | 481 | ))} |
| 447 | <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead> | 482 | <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead> |
| @@ -468,13 +503,39 @@ export function LabelTemplateDataEntryView({ | @@ -468,13 +503,39 @@ export function LabelTemplateDataEntryView({ | ||
| 468 | searchPlaceholder="Search type…" | 503 | searchPlaceholder="Search type…" |
| 469 | /> | 504 | /> |
| 470 | </TableCell> | 505 | </TableCell> |
| 471 | - {printFields.map((f) => ( | ||
| 472 | - <TableCell key={f.id} className="align-top py-2"> | ||
| 473 | - <DataEntryValueCell | ||
| 474 | - element={f} | ||
| 475 | - value={row.fieldValues[f.id] ?? ''} | ||
| 476 | - onValueChange={(v) => setFieldValue(row.id, f.id, v)} | ||
| 477 | - /> | 506 | + {dataColumns.map((col) => ( |
| 507 | + <TableCell | ||
| 508 | + key={ | ||
| 509 | + col.kind === 'element' | ||
| 510 | + ? col.el.id | ||
| 511 | + : nutritionCompositeFieldKey(col.parent.id, col.spec.subKey) | ||
| 512 | + } | ||
| 513 | + className="align-top py-2" | ||
| 514 | + > | ||
| 515 | + {col.kind === 'element' ? ( | ||
| 516 | + <DataEntryValueCell | ||
| 517 | + element={col.el} | ||
| 518 | + value={row.fieldValues[col.el.id] ?? ''} | ||
| 519 | + onValueChange={(v) => setFieldValue(row.id, col.el.id, v)} | ||
| 520 | + /> | ||
| 521 | + ) : ( | ||
| 522 | + <Input | ||
| 523 | + value={ | ||
| 524 | + row.fieldValues[ | ||
| 525 | + nutritionCompositeFieldKey(col.parent.id, col.spec.subKey) | ||
| 526 | + ] ?? '' | ||
| 527 | + } | ||
| 528 | + onChange={(e) => | ||
| 529 | + setFieldValue( | ||
| 530 | + row.id, | ||
| 531 | + nutritionCompositeFieldKey(col.parent.id, col.spec.subKey), | ||
| 532 | + e.target.value, | ||
| 533 | + ) | ||
| 534 | + } | ||
| 535 | + placeholder="—" | ||
| 536 | + className="h-10 border-gray-300 max-w-[220px]" | ||
| 537 | + /> | ||
| 538 | + )} | ||
| 478 | </TableCell> | 539 | </TableCell> |
| 479 | ))} | 540 | ))} |
| 480 | <TableCell className="text-center align-top py-2"> | 541 | <TableCell className="text-center align-top py-2"> |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/ElementsPanel.tsx
| @@ -83,17 +83,62 @@ interface ElementsPanelProps { | @@ -83,17 +83,62 @@ interface ElementsPanelProps { | ||
| 83 | ) => void; | 83 | ) => void; |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | +function sectionSurfaceStyle(title: string): React.CSSProperties { | ||
| 87 | + const t = title.trim().toLowerCase(); | ||
| 88 | + if (t === "template") { | ||
| 89 | + return { | ||
| 90 | + backgroundColor: "#dbeafe", | ||
| 91 | + border: "1px solid #93c5fd", | ||
| 92 | + borderRadius: 8, | ||
| 93 | + padding: 8, | ||
| 94 | + }; | ||
| 95 | + } | ||
| 96 | + if (t === "label") { | ||
| 97 | + return { | ||
| 98 | + backgroundColor: "#e0f2fe", | ||
| 99 | + border: "1px solid #7dd3fc", | ||
| 100 | + borderRadius: 8, | ||
| 101 | + padding: 8, | ||
| 102 | + }; | ||
| 103 | + } | ||
| 104 | + if (t === "auto-generated") { | ||
| 105 | + return { | ||
| 106 | + backgroundColor: "#ede9fe", | ||
| 107 | + border: "1px solid #c4b5fd", | ||
| 108 | + borderRadius: 8, | ||
| 109 | + padding: 8, | ||
| 110 | + }; | ||
| 111 | + } | ||
| 112 | + if (t === "print input") { | ||
| 113 | + return { | ||
| 114 | + backgroundColor: "#ffedd5", | ||
| 115 | + border: "1px solid #fdba74", | ||
| 116 | + borderRadius: 8, | ||
| 117 | + padding: 8, | ||
| 118 | + }; | ||
| 119 | + } | ||
| 120 | + return { | ||
| 121 | + backgroundColor: "#f1f5f9", | ||
| 122 | + border: "1px solid #cbd5e1", | ||
| 123 | + borderRadius: 8, | ||
| 124 | + padding: 8, | ||
| 125 | + }; | ||
| 126 | +} | ||
| 127 | + | ||
| 86 | export function ElementsPanel({ onAddElement }: ElementsPanelProps) { | 128 | export function ElementsPanel({ onAddElement }: ElementsPanelProps) { |
| 87 | return ( | 129 | return ( |
| 88 | - <div className="w-44 shrink-0 border-r border-gray-200 bg-white flex flex-col h-full"> | ||
| 89 | - <div className="px-2 py-2 border-b border-gray-200 font-semibold text-gray-800 text-sm"> | 130 | + <div className="w-44 shrink-0 border-r border-slate-200 bg-slate-50 flex flex-col h-full min-h-0"> |
| 131 | + <div | ||
| 132 | + className="px-2 py-2 border-b border-slate-200 font-semibold text-slate-800 text-sm shrink-0" | ||
| 133 | + style={{ backgroundColor: "#eff6ff", borderBottomColor: "#93c5fd" }} | ||
| 134 | + > | ||
| 90 | Elements | 135 | Elements |
| 91 | </div> | 136 | </div> |
| 92 | - <ScrollArea className="flex-1"> | ||
| 93 | - <div className="p-1.5 space-y-3"> | 137 | + <ScrollArea className="flex-1 min-h-0 [&_[data-slot=scroll-area-viewport]]:bg-transparent"> |
| 138 | + <div className="p-2 space-y-3"> | ||
| 94 | {ELEMENT_CATEGORIES.map((cat) => ( | 139 | {ELEMENT_CATEGORIES.map((cat) => ( |
| 95 | - <div key={cat.title}> | ||
| 96 | - <div className="px-2 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide"> | 140 | + <div key={cat.title} style={sectionSurfaceStyle(cat.title)}> |
| 141 | + <div className="px-2 py-1 text-xs font-medium text-gray-600 uppercase tracking-wide"> | ||
| 97 | {cat.title} | 142 | {cat.title} |
| 98 | </div> | 143 | </div> |
| 99 | {cat.subtitle && ( | 144 | {cat.subtitle && ( |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
| @@ -225,8 +225,9 @@ function RulerBarHorizontal({ | @@ -225,8 +225,9 @@ function RulerBarHorizontal({ | ||
| 225 | } | 225 | } |
| 226 | const h = RULER_H; | 226 | const h = RULER_H; |
| 227 | const pxPerDisplayUnit = paperWidthPx / displaySpan; | 227 | const pxPerDisplayUnit = paperWidthPx / displaySpan; |
| 228 | - const totalU = Math.max(displaySpan, rulerTotalWidthPx / pxPerDisplayUnit); | ||
| 229 | - const xAtU = (u: number) => u * pxPerDisplayUnit; | 228 | + /** 标尺几何中心为刻度 0,向左为负、向右为正 */ |
| 229 | + const centerPx = rulerTotalWidthPx / 2; | ||
| 230 | + const xAtSignedUnit = (u: number) => centerPx + u * pxPerDisplayUnit; | ||
| 230 | const nodes: React.ReactNode[] = []; | 231 | const nodes: React.ReactNode[] = []; |
| 231 | 232 | ||
| 232 | let labelStep = 1; | 233 | let labelStep = 1; |
| @@ -237,38 +238,37 @@ function RulerBarHorizontal({ | @@ -237,38 +238,37 @@ function RulerBarHorizontal({ | ||
| 237 | } | 238 | } |
| 238 | 239 | ||
| 239 | const minorDivisions = displayUnit === "inch" ? 8 : 10; | 240 | const minorDivisions = displayUnit === "inch" ? 8 : 10; |
| 240 | - const intMax = Math.min(5000, Math.floor(totalU + 1e-6)); | ||
| 241 | - for (let k = 0; k <= intMax; k++) { | ||
| 242 | - if (k > totalU + 1e-6) break; | ||
| 243 | - const x = xAtU(k); | 241 | + const kMin = Math.floor((0 - centerPx) / pxPerDisplayUnit) - 2; |
| 242 | + const kMax = Math.ceil((rulerTotalWidthPx - centerPx) / pxPerDisplayUnit) + 2; | ||
| 243 | + const kLo = Math.max(-5000, Math.min(5000, kMin)); | ||
| 244 | + const kHi = Math.max(-5000, Math.min(5000, kMax)); | ||
| 245 | + | ||
| 246 | + for (let k = kLo; k <= kHi; k++) { | ||
| 247 | + const x = xAtSignedUnit(k); | ||
| 248 | + if (x < -8 || x > rulerTotalWidthPx + 8) continue; | ||
| 244 | const showLabel = k === 0 || k % labelStep === 0; | 249 | const showLabel = k === 0 || k % labelStep === 0; |
| 245 | nodes.push( | 250 | nodes.push( |
| 246 | <g key={`maj-${k}`}> | 251 | <g key={`maj-${k}`}> |
| 247 | <line x1={x} y1={h} x2={x} y2={4} stroke="#9ca3af" strokeWidth={1} /> | 252 | <line x1={x} y1={h} x2={x} y2={4} stroke="#9ca3af" strokeWidth={1} /> |
| 248 | {showLabel ? ( | 253 | {showLabel ? ( |
| 249 | <text | 254 | <text |
| 250 | - x={k === 0 ? 3 : x} | 255 | + x={x} |
| 251 | y={12} | 256 | y={12} |
| 252 | fontSize={8} | 257 | fontSize={8} |
| 253 | fill="#4b5563" | 258 | fill="#4b5563" |
| 254 | className="select-none font-mono" | 259 | className="select-none font-mono" |
| 255 | - textAnchor={k === 0 ? "start" : "middle"} | 260 | + textAnchor="middle" |
| 256 | > | 261 | > |
| 257 | {k} | 262 | {k} |
| 258 | </text> | 263 | </text> |
| 259 | ) : null} | 264 | ) : null} |
| 260 | </g>, | 265 | </g>, |
| 261 | ); | 266 | ); |
| 262 | - const next = Math.min(k + 1, totalU); | ||
| 263 | - if (next - k < 0.0001) continue; | ||
| 264 | - if (k + 1e-6 >= totalU) break; | ||
| 265 | - const partEnd = Math.min(k + 1, totalU); | ||
| 266 | const midMinor = Math.floor(minorDivisions / 2); | 267 | const midMinor = Math.floor(minorDivisions / 2); |
| 267 | for (let s = 1; s < minorDivisions; s++) { | 268 | for (let s = 1; s < minorDivisions; s++) { |
| 268 | const u = k + s / minorDivisions; | 269 | const u = k + s / minorDivisions; |
| 269 | - if (u >= totalU) break; | ||
| 270 | - if (u > partEnd + 1e-9) break; | ||
| 271 | - const x2 = xAtU(u); | 270 | + const x2 = xAtSignedUnit(u); |
| 271 | + if (x2 < -4 || x2 > rulerTotalWidthPx + 4) continue; | ||
| 272 | const y2 = s === midMinor ? 10 : 12; | 272 | const y2 = s === midMinor ? 10 : 12; |
| 273 | nodes.push( | 273 | nodes.push( |
| 274 | <line | 274 | <line |
| @@ -936,6 +936,8 @@ interface LabelCanvasProps { | @@ -936,6 +936,8 @@ interface LabelCanvasProps { | ||
| 936 | scale?: number; | 936 | scale?: number; |
| 937 | onZoomIn?: () => void; | 937 | onZoomIn?: () => void; |
| 938 | onZoomOut?: () => void; | 938 | onZoomOut?: () => void; |
| 939 | + /** 将缩放还原为 100%(与顶部标尺物理尺寸一致),并重新居中画布 */ | ||
| 940 | + onResetZoom?: () => void; | ||
| 939 | onPreview?: () => void; | 941 | onPreview?: () => void; |
| 940 | /** 为 true 时不在预览工具栏显示画布尺寸预设(改由顶部表单控制) */ | 942 | /** 为 true 时不在预览工具栏显示画布尺寸预设(改由顶部表单控制) */ |
| 941 | hideToolbarPresetSize?: boolean; | 943 | hideToolbarPresetSize?: boolean; |
| @@ -969,6 +971,7 @@ export function LabelCanvas({ | @@ -969,6 +971,7 @@ export function LabelCanvas({ | ||
| 969 | scale = 1, | 971 | scale = 1, |
| 970 | onZoomIn, | 972 | onZoomIn, |
| 971 | onZoomOut, | 973 | onZoomOut, |
| 974 | + onResetZoom, | ||
| 972 | onPreview, | 975 | onPreview, |
| 973 | hideToolbarPresetSize = false, | 976 | hideToolbarPresetSize = false, |
| 974 | }: LabelCanvasProps) { | 977 | }: LabelCanvasProps) { |
| @@ -1006,6 +1009,7 @@ export function LabelCanvas({ | @@ -1006,6 +1009,7 @@ export function LabelCanvas({ | ||
| 1006 | 1009 | ||
| 1007 | const baseW = unitToPx(template.width, template.unit); | 1010 | const baseW = unitToPx(template.width, template.unit); |
| 1008 | const baseH = unitToPx(template.height, template.unit); | 1011 | const baseH = unitToPx(template.height, template.unit); |
| 1012 | + /** 缩放后的实际占位,用于滚动区域与居中,避免放大后画布被裁切 */ | ||
| 1009 | const widthPx = baseW * scale; | 1013 | const widthPx = baseW * scale; |
| 1010 | const heightPx = baseH * scale; | 1014 | const heightPx = baseH * scale; |
| 1011 | const showGrid = template.showGrid !== false; | 1015 | const showGrid = template.showGrid !== false; |
| @@ -1343,21 +1347,23 @@ export function LabelCanvas({ | @@ -1343,21 +1347,23 @@ export function LabelCanvas({ | ||
| 1343 | }; | 1347 | }; |
| 1344 | }, [paperResizeCursor]); | 1348 | }, [paperResizeCursor]); |
| 1345 | 1349 | ||
| 1346 | - // 画布初始居中:挂载或尺寸/缩放变化后让内容居中 | ||
| 1347 | - useEffect(() => { | 1350 | + const centerScrollInViewport = useCallback(() => { |
| 1348 | const el = scrollContainerRef.current; | 1351 | const el = scrollContainerRef.current; |
| 1349 | if (!el) return; | 1352 | if (!el) return; |
| 1350 | - const center = () => { | 1353 | + const run = () => { |
| 1351 | el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2); | 1354 | el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2); |
| 1352 | el.scrollTop = Math.max(0, (el.scrollHeight - el.clientHeight) / 2); | 1355 | el.scrollTop = Math.max(0, (el.scrollHeight - el.clientHeight) / 2); |
| 1353 | }; | 1356 | }; |
| 1354 | - const raf = requestAnimationFrame(center); | ||
| 1355 | - const t = setTimeout(center, 100); | ||
| 1356 | - return () => { | ||
| 1357 | - cancelAnimationFrame(raf); | ||
| 1358 | - clearTimeout(t); | ||
| 1359 | - }; | ||
| 1360 | - }, [scale, baseW, baseH]); | 1357 | + requestAnimationFrame(() => requestAnimationFrame(run)); |
| 1358 | + }, []); | ||
| 1359 | + | ||
| 1360 | + // 缩放或纸张尺寸变化:清空平移偏移,并把画布重新滚到视口中央,避免放大后靠边被遮挡 | ||
| 1361 | + useEffect(() => { | ||
| 1362 | + setPanOffset({ x: 0, y: 0 }); | ||
| 1363 | + centerScrollInViewport(); | ||
| 1364 | + const t = window.setTimeout(centerScrollInViewport, 80); | ||
| 1365 | + return () => window.clearTimeout(t); | ||
| 1366 | + }, [scale, baseW, baseH, rulerLayoutWidth, centerScrollInViewport]); | ||
| 1361 | 1367 | ||
| 1362 | // Keyboard navigation for elements | 1368 | // Keyboard navigation for elements |
| 1363 | const handleKeyDown = useCallback((e: React.KeyboardEvent) => { | 1369 | const handleKeyDown = useCallback((e: React.KeyboardEvent) => { |
| @@ -1522,6 +1528,16 @@ export function LabelCanvas({ | @@ -1522,6 +1528,16 @@ export function LabelCanvas({ | ||
| 1522 | + | 1528 | + |
| 1523 | </button> | 1529 | </button> |
| 1524 | </div> | 1530 | </div> |
| 1531 | + {onResetZoom ? ( | ||
| 1532 | + <button | ||
| 1533 | + type="button" | ||
| 1534 | + onClick={onResetZoom} | ||
| 1535 | + className="h-8 px-3 rounded border border-blue-200 bg-blue-50 text-blue-800 hover:bg-blue-100 text-xs font-medium shadow-sm transition-all active:scale-95 shrink-0" | ||
| 1536 | + title="Reset zoom to 100% (match ruler canvas size, e.g. 3×2 inch)" | ||
| 1537 | + > | ||
| 1538 | + Restore size | ||
| 1539 | + </button> | ||
| 1540 | + ) : null} | ||
| 1525 | <Select | 1541 | <Select |
| 1526 | value={previewRulerUnit} | 1542 | value={previewRulerUnit} |
| 1527 | onValueChange={(v: PreviewRulerDisplayUnit) => setPreviewRulerUnit(v)} | 1543 | onValueChange={(v: PreviewRulerDisplayUnit) => setPreviewRulerUnit(v)} |
| @@ -1587,12 +1603,12 @@ export function LabelCanvas({ | @@ -1587,12 +1603,12 @@ export function LabelCanvas({ | ||
| 1587 | /> | 1603 | /> |
| 1588 | </div> | 1604 | </div> |
| 1589 | <div className="flex w-full min-w-0 justify-center"> | 1605 | <div className="flex w-full min-w-0 justify-center"> |
| 1590 | - <div className="shrink-0" style={{ width: widthPx }}> | 1606 | + <div className="shrink-0 relative overflow-visible" style={{ width: widthPx, height: heightPx }}> |
| 1591 | <div | 1607 | <div |
| 1592 | ref={canvasRef} | 1608 | ref={canvasRef} |
| 1593 | tabIndex={0} | 1609 | tabIndex={0} |
| 1594 | className={cn( | 1610 | className={cn( |
| 1595 | - 'relative bg-white shadow-lg origin-top-left outline-none', | 1611 | + 'absolute left-0 top-0 bg-white shadow-lg outline-none', |
| 1596 | canvasBorderClass, | 1612 | canvasBorderClass, |
| 1597 | isPanning ? 'cursor-grabbing' : 'cursor-grab' | 1613 | isPanning ? 'cursor-grabbing' : 'cursor-grab' |
| 1598 | )} | 1614 | )} |
| @@ -1600,6 +1616,7 @@ export function LabelCanvas({ | @@ -1600,6 +1616,7 @@ export function LabelCanvas({ | ||
| 1600 | width: baseW, | 1616 | width: baseW, |
| 1601 | height: baseH, | 1617 | height: baseH, |
| 1602 | transform: `scale(${scale})`, | 1618 | transform: `scale(${scale})`, |
| 1619 | + transformOrigin: 'top left', | ||
| 1603 | backgroundImage: showGrid | 1620 | backgroundImage: showGrid |
| 1604 | ? `linear-gradient(to right, rgba(0,0,0,0.06) 1px, transparent 1px), | 1621 | ? `linear-gradient(to right, rgba(0,0,0,0.06) 1px, transparent 1px), |
| 1605 | linear-gradient(to bottom, rgba(0,0,0,0.06) 1px, transparent 1px)` | 1622 | linear-gradient(to bottom, rgba(0,0,0,0.06) 1px, transparent 1px)` |
| @@ -1657,18 +1674,38 @@ export function LabelCanvas({ | @@ -1657,18 +1674,38 @@ export function LabelCanvas({ | ||
| 1657 | onPointerUp={handlePointerUp} | 1674 | onPointerUp={handlePointerUp} |
| 1658 | onKeyDown={handleKeyDown} | 1675 | onKeyDown={handleKeyDown} |
| 1659 | > | 1676 | > |
| 1660 | - {/* 主题色虚线安全区:控件不可移出(与 LABEL_CANVAS_SAFE_MARGIN_PX 一致) */} | ||
| 1661 | - <div | ||
| 1662 | - className="pointer-events-none absolute z-[1] box-border rounded-sm" | ||
| 1663 | - style={{ | ||
| 1664 | - top: LABEL_CANVAS_SAFE_MARGIN_PX, | ||
| 1665 | - left: LABEL_CANVAS_SAFE_MARGIN_PX, | ||
| 1666 | - right: LABEL_CANVAS_SAFE_MARGIN_PX, | ||
| 1667 | - bottom: LABEL_CANVAS_SAFE_MARGIN_PX, | ||
| 1668 | - border: '2px dashed var(--primary)', | ||
| 1669 | - }} | ||
| 1670 | - aria-hidden | ||
| 1671 | - /> | 1677 | + {/* 选中元素对齐参考线:延伸至画布四边,蓝色虚线 */} |
| 1678 | + {selectedId | ||
| 1679 | + ? (() => { | ||
| 1680 | + const el = template.elements.find((e) => e.id === selectedId); | ||
| 1681 | + if (!el) return null; | ||
| 1682 | + const lineCls = "pointer-events-none absolute z-[2] border-blue-600"; | ||
| 1683 | + return ( | ||
| 1684 | + <> | ||
| 1685 | + <div | ||
| 1686 | + className={cn(lineCls, "left-0 right-0 border-t border-dashed")} | ||
| 1687 | + style={{ top: el.y }} | ||
| 1688 | + aria-hidden | ||
| 1689 | + /> | ||
| 1690 | + <div | ||
| 1691 | + className={cn(lineCls, "left-0 right-0 border-t border-dashed")} | ||
| 1692 | + style={{ top: el.y + el.height }} | ||
| 1693 | + aria-hidden | ||
| 1694 | + /> | ||
| 1695 | + <div | ||
| 1696 | + className={cn(lineCls, "top-0 bottom-0 border-l border-dashed")} | ||
| 1697 | + style={{ left: el.x }} | ||
| 1698 | + aria-hidden | ||
| 1699 | + /> | ||
| 1700 | + <div | ||
| 1701 | + className={cn(lineCls, "top-0 bottom-0 border-l border-dashed")} | ||
| 1702 | + style={{ left: el.x + el.width }} | ||
| 1703 | + aria-hidden | ||
| 1704 | + /> | ||
| 1705 | + </> | ||
| 1706 | + ); | ||
| 1707 | + })() | ||
| 1708 | + : null} | ||
| 1672 | {/* Paper resize: top */} | 1709 | {/* Paper resize: top */} |
| 1673 | {onTemplateChange && ( | 1710 | {onTemplateChange && ( |
| 1674 | <div | 1711 | <div |
| @@ -1755,7 +1792,7 @@ export function LabelCanvas({ | @@ -1755,7 +1792,7 @@ export function LabelCanvas({ | ||
| 1755 | {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( | 1792 | {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( |
| 1756 | <div | 1793 | <div |
| 1757 | key={corner} | 1794 | key={corner} |
| 1758 | - className="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-full z-20 shadow-md hover:scale-110 transition-transform" | 1795 | + className="absolute w-3.5 h-3.5 bg-white border-2 border-blue-600 rounded-none z-20 shadow-sm hover:scale-110 transition-transform" |
| 1759 | style={{ | 1796 | style={{ |
| 1760 | cursor: 'nwse-resize', | 1797 | cursor: 'nwse-resize', |
| 1761 | top: corner.startsWith('n') ? -6 : undefined, | 1798 | top: corner.startsWith('n') ? -6 : undefined, |
| @@ -1784,7 +1821,7 @@ export function LabelCanvas({ | @@ -1784,7 +1821,7 @@ export function LabelCanvas({ | ||
| 1784 | {(['n', 's', 'w', 'e'] as const).map((edge) => ( | 1821 | {(['n', 's', 'w', 'e'] as const).map((edge) => ( |
| 1785 | <div | 1822 | <div |
| 1786 | key={edge} | 1823 | key={edge} |
| 1787 | - className="absolute bg-blue-500/50 border border-white/50 rounded-sm z-10 shadow-sm hover:bg-blue-600" | 1824 | + className="absolute bg-white border-2 border-blue-600 rounded-none z-10 shadow-sm hover:bg-blue-50" |
| 1788 | style={{ | 1825 | style={{ |
| 1789 | cursor: edge === 'n' || edge === 's' ? 'ns-resize' : 'ew-resize', | 1826 | cursor: edge === 'n' || edge === 's' ? 'ns-resize' : 'ew-resize', |
| 1790 | width: edge === 'n' || edge === 's' ? '20px' : '6px', | 1827 | width: edge === 'n' || edge === 's' ? '20px' : '6px', |
| @@ -1823,6 +1860,46 @@ export function LabelCanvas({ | @@ -1823,6 +1860,46 @@ export function LabelCanvas({ | ||
| 1823 | </div> | 1860 | </div> |
| 1824 | ); | 1861 | ); |
| 1825 | })} | 1862 | })} |
| 1863 | + {baseW > LABEL_CANVAS_SAFE_MARGIN_PX * 2 && baseH > LABEL_CANVAS_SAFE_MARGIN_PX * 2 ? ( | ||
| 1864 | + <svg | ||
| 1865 | + className="pointer-events-none absolute left-0 top-0 z-[12]" | ||
| 1866 | + width={baseW} | ||
| 1867 | + height={baseH} | ||
| 1868 | + style={{ overflow: "visible" }} | ||
| 1869 | + aria-hidden | ||
| 1870 | + > | ||
| 1871 | + {(() => { | ||
| 1872 | + const m = LABEL_CANVAS_SAFE_MARGIN_PX; | ||
| 1873 | + const stroke = "#2563eb"; | ||
| 1874 | + const sw = 2; | ||
| 1875 | + const dash = "10 6"; | ||
| 1876 | + return ( | ||
| 1877 | + <> | ||
| 1878 | + <line x1={0} y1={m} x2={baseW} y2={m} stroke={stroke} strokeWidth={sw} strokeDasharray={dash} /> | ||
| 1879 | + <line | ||
| 1880 | + x1={0} | ||
| 1881 | + y1={baseH - m} | ||
| 1882 | + x2={baseW} | ||
| 1883 | + y2={baseH - m} | ||
| 1884 | + stroke={stroke} | ||
| 1885 | + strokeWidth={sw} | ||
| 1886 | + strokeDasharray={dash} | ||
| 1887 | + /> | ||
| 1888 | + <line x1={m} y1={0} x2={m} y2={baseH} stroke={stroke} strokeWidth={sw} strokeDasharray={dash} /> | ||
| 1889 | + <line | ||
| 1890 | + x1={baseW - m} | ||
| 1891 | + y1={0} | ||
| 1892 | + x2={baseW - m} | ||
| 1893 | + y2={baseH} | ||
| 1894 | + stroke={stroke} | ||
| 1895 | + strokeWidth={sw} | ||
| 1896 | + strokeDasharray={dash} | ||
| 1897 | + /> | ||
| 1898 | + </> | ||
| 1899 | + ); | ||
| 1900 | + })()} | ||
| 1901 | + </svg> | ||
| 1902 | + ) : null} | ||
| 1826 | </div> | 1903 | </div> |
| 1827 | </div> | 1904 | </div> |
| 1828 | </div> | 1905 | </div> |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
| @@ -19,7 +19,13 @@ import type { | @@ -19,7 +19,13 @@ import type { | ||
| 19 | Border, | 19 | Border, |
| 20 | NutritionExtraItem, | 20 | NutritionExtraItem, |
| 21 | } from '../../../types/labelTemplate'; | 21 | } from '../../../types/labelTemplate'; |
| 22 | -import { canonicalElementType, isBlankSpaceElement, NUTRITION_FIXED_ITEMS } from '../../../types/labelTemplate'; | 22 | +import { |
| 23 | + canonicalElementType, | ||
| 24 | + isBlankSpaceElement, | ||
| 25 | + isTemplateSectionPersistedType, | ||
| 26 | + NUTRITION_FIXED_ITEMS, | ||
| 27 | +} from '../../../types/labelTemplate'; | ||
| 28 | +import { ImageUrlUpload } from '../../ui/image-url-upload'; | ||
| 23 | import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; | 29 | import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; |
| 24 | import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; | 30 | import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; |
| 25 | import { Checkbox } from '../../ui/checkbox'; | 31 | import { Checkbox } from '../../ui/checkbox'; |
| @@ -354,19 +360,26 @@ function MultipleOptionsDictionaryFields({ | @@ -354,19 +360,26 @@ function MultipleOptionsDictionaryFields({ | ||
| 354 | ); | 360 | ); |
| 355 | } | 361 | } |
| 356 | 362 | ||
| 363 | +const TEMPLATE_IMAGE_UPLOAD_BOX = | ||
| 364 | + 'box-border h-[150px] w-[150px] min-h-[150px] min-w-[150px] max-h-[150px] max-w-[150px] shrink-0'; | ||
| 365 | + | ||
| 357 | function TextStaticStyleFields({ | 366 | function TextStaticStyleFields({ |
| 358 | cfg, | 367 | cfg, |
| 359 | update, | 368 | update, |
| 360 | textAlignDefault, | 369 | textAlignDefault, |
| 370 | + primaryTextLabel, | ||
| 361 | }: { | 371 | }: { |
| 362 | cfg: Record<string, unknown>; | 372 | cfg: Record<string, unknown>; |
| 363 | update: (key: string, value: unknown) => void; | 373 | update: (key: string, value: unknown) => void; |
| 364 | textAlignDefault: string; | 374 | textAlignDefault: string; |
| 375 | + /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */ | ||
| 376 | + primaryTextLabel?: 'Text' | 'Value'; | ||
| 365 | }) { | 377 | }) { |
| 378 | + const textLabel = primaryTextLabel ?? 'Text'; | ||
| 366 | return ( | 379 | return ( |
| 367 | <> | 380 | <> |
| 368 | <div> | 381 | <div> |
| 369 | - <Label className="text-xs">Text</Label> | 382 | + <Label className="text-xs">{textLabel}</Label> |
| 370 | <Input | 383 | <Input |
| 371 | value={(cfg.text as string) ?? '0.00'} | 384 | value={(cfg.text as string) ?? '0.00'} |
| 372 | onChange={(e) => update('text', e.target.value)} | 385 | onChange={(e) => update('text', e.target.value)} |
| @@ -480,6 +493,8 @@ function ElementConfigFields({ | @@ -480,6 +493,8 @@ function ElementConfigFields({ | ||
| 480 | const elementType = canonicalElementType(element.type); | 493 | const elementType = canonicalElementType(element.type); |
| 481 | const update = (key: string, value: unknown) => | 494 | const update = (key: string, value: unknown) => |
| 482 | onChange({ [key]: value }); | 495 | onChange({ [key]: value }); |
| 496 | + const fromTemplatePalette = isTemplateSectionPersistedType(element); | ||
| 497 | + const staticTextLabel = fromTemplatePalette ? ('Value' as const) : ('Text' as const); | ||
| 483 | 498 | ||
| 484 | switch (elementType) { | 499 | switch (elementType) { |
| 485 | case 'TEXT_STATIC': | 500 | case 'TEXT_STATIC': |
| @@ -487,11 +502,23 @@ function ElementConfigFields({ | @@ -487,11 +502,23 @@ function ElementConfigFields({ | ||
| 487 | return ( | 502 | return ( |
| 488 | <> | 503 | <> |
| 489 | <MultipleOptionsDictionaryFields cfg={cfg} onPatch={onChange} /> | 504 | <MultipleOptionsDictionaryFields cfg={cfg} onPatch={onChange} /> |
| 490 | - <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="left" /> | 505 | + <TextStaticStyleFields |
| 506 | + cfg={cfg} | ||
| 507 | + update={update} | ||
| 508 | + textAlignDefault="left" | ||
| 509 | + primaryTextLabel={staticTextLabel} | ||
| 510 | + /> | ||
| 491 | </> | 511 | </> |
| 492 | ); | 512 | ); |
| 493 | } | 513 | } |
| 494 | - return <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="right" />; | 514 | + return ( |
| 515 | + <TextStaticStyleFields | ||
| 516 | + cfg={cfg} | ||
| 517 | + update={update} | ||
| 518 | + textAlignDefault="right" | ||
| 519 | + primaryTextLabel={staticTextLabel} | ||
| 520 | + /> | ||
| 521 | + ); | ||
| 495 | case 'TEXT_PRODUCT': | 522 | case 'TEXT_PRODUCT': |
| 496 | case 'TEXT_PRICE': | 523 | case 'TEXT_PRICE': |
| 497 | return <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="right" />; | 524 | return <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="right" />; |
| @@ -541,7 +568,41 @@ function ElementConfigFields({ | @@ -541,7 +568,41 @@ function ElementConfigFields({ | ||
| 541 | /> | 568 | /> |
| 542 | </div> | 569 | </div> |
| 543 | ); | 570 | ); |
| 544 | - case 'IMAGE': | 571 | + case 'IMAGE': { |
| 572 | + if (fromTemplatePalette) { | ||
| 573 | + const src = String(cfg.src ?? '').trim(); | ||
| 574 | + return ( | ||
| 575 | + <> | ||
| 576 | + <div> | ||
| 577 | + <Label className="text-xs">Image</Label> | ||
| 578 | + <ImageUrlUpload | ||
| 579 | + value={src} | ||
| 580 | + onChange={(url) => update('src', url)} | ||
| 581 | + uploadSubDir="label-template-editor" | ||
| 582 | + oneImageOnly | ||
| 583 | + boxClassName={TEMPLATE_IMAGE_UPLOAD_BOX} | ||
| 584 | + hint="Stored in template; print uses this URL (empty if cleared)." | ||
| 585 | + /> | ||
| 586 | + </div> | ||
| 587 | + <div> | ||
| 588 | + <Label className="text-xs">Scale Mode</Label> | ||
| 589 | + <Select | ||
| 590 | + value={(cfg.scaleMode as string) ?? 'contain'} | ||
| 591 | + onValueChange={(v) => update('scaleMode', v)} | ||
| 592 | + > | ||
| 593 | + <SelectTrigger className="h-8 text-sm mt-1"> | ||
| 594 | + <SelectValue /> | ||
| 595 | + </SelectTrigger> | ||
| 596 | + <SelectContent> | ||
| 597 | + <SelectItem value="contain">Contain</SelectItem> | ||
| 598 | + <SelectItem value="cover">Cover</SelectItem> | ||
| 599 | + <SelectItem value="fill">Fill</SelectItem> | ||
| 600 | + </SelectContent> | ||
| 601 | + </Select> | ||
| 602 | + </div> | ||
| 603 | + </> | ||
| 604 | + ); | ||
| 605 | + } | ||
| 545 | return ( | 606 | return ( |
| 546 | <> | 607 | <> |
| 547 | <div> | 608 | <div> |
| @@ -571,6 +632,7 @@ function ElementConfigFields({ | @@ -571,6 +632,7 @@ function ElementConfigFields({ | ||
| 571 | </div> | 632 | </div> |
| 572 | </> | 633 | </> |
| 573 | ); | 634 | ); |
| 635 | + } | ||
| 574 | case 'DATE': { | 636 | case 'DATE': { |
| 575 | const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase(); | 637 | const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase(); |
| 576 | const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date'; | 638 | const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date'; |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
| @@ -608,6 +608,7 @@ export function LabelTemplateEditor({ | @@ -608,6 +608,7 @@ export function LabelTemplateEditor({ | ||
| 608 | scale={scale} | 608 | scale={scale} |
| 609 | onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))} | 609 | onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))} |
| 610 | onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))} | 610 | onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))} |
| 611 | + onResetZoom={() => setScale(DEFAULT_SCALE)} | ||
| 611 | onPreview={() => setPreviewOpen(true)} | 612 | onPreview={() => setPreviewOpen(true)} |
| 612 | hideToolbarPresetSize | 613 | hideToolbarPresetSize |
| 613 | /> | 614 | /> |
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
| @@ -74,8 +74,17 @@ import { | @@ -74,8 +74,17 @@ import { | ||
| 74 | applyOffsetToDate, | 74 | applyOffsetToDate, |
| 75 | formatDateByPreset, | 75 | formatDateByPreset, |
| 76 | LABEL_FORM_OFFSET_UNITS, | 76 | LABEL_FORM_OFFSET_UNITS, |
| 77 | + normalizeLabelFormOffsetInput, | ||
| 77 | serializePrintInputOffset, | 78 | serializePrintInputOffset, |
| 78 | } from "../../lib/labelFormDatePreview"; | 79 | } from "../../lib/labelFormDatePreview"; |
| 80 | +import { | ||
| 81 | + listNutritionElements, | ||
| 82 | + listNutritionManualFieldSpecs, | ||
| 83 | + mergeNutritionManualIntoConfig, | ||
| 84 | + nutritionDefaultValuesJsonForSave, | ||
| 85 | + nutritionManualValuesFromTemplateConfig, | ||
| 86 | + type NutritionManualFieldSpec, | ||
| 87 | +} from "../../lib/nutritionManualEntry"; | ||
| 79 | 88 | ||
| 80 | function toDisplay(v: string | null | undefined): string { | 89 | function toDisplay(v: string | null | undefined): string { |
| 81 | const s = (v ?? "").trim(); | 90 | const s = (v ?? "").trim(); |
| @@ -141,6 +150,7 @@ function buildCreateLabelPreviewTemplate( | @@ -141,6 +150,7 @@ function buildCreateLabelPreviewTemplate( | ||
| 141 | apiTpl: LabelTemplateDto | null, | 150 | apiTpl: LabelTemplateDto | null, |
| 142 | textValues: Record<string, string>, | 151 | textValues: Record<string, string>, |
| 143 | dateOffsets: Record<string, { unit: string; value: string }>, | 152 | dateOffsets: Record<string, { unit: string; value: string }>, |
| 153 | + nutritionByElementId: Record<string, Record<string, string>>, | ||
| 144 | ): LabelTemplate | null { | 154 | ): LabelTemplate | null { |
| 145 | if (!apiTpl) return null; | 155 | if (!apiTpl) return null; |
| 146 | const tmpl = dtoToEditorTemplate(apiTpl); | 156 | const tmpl = dtoToEditorTemplate(apiTpl); |
| @@ -153,11 +163,12 @@ function buildCreateLabelPreviewTemplate( | @@ -153,11 +163,12 @@ function buildCreateLabelPreviewTemplate( | ||
| 153 | const type = canonicalElementType(el.type); | 163 | const type = canonicalElementType(el.type); |
| 154 | if (isDateTimeDataEntryField(el)) { | 164 | if (isDateTimeDataEntryField(el)) { |
| 155 | const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; | 165 | const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; |
| 156 | - const amount = Number(String(pair.value).trim()); | ||
| 157 | const unit = pair.unit || "Days"; | 166 | const unit = pair.unit || "Days"; |
| 158 | - if (!Number.isFinite(amount) || String(pair.value).trim() === "") { | 167 | + const norm = normalizeLabelFormOffsetInput(pair.value); |
| 168 | + if (norm.kind === "invalid") { | ||
| 159 | cfg.__previewFormatted = ""; | 169 | cfg.__previewFormatted = ""; |
| 160 | } else { | 170 | } else { |
| 171 | + const amount = norm.kind === "zero" ? 0 : norm.amount; | ||
| 161 | const d = applyOffsetToDate(now, amount, unit); | 172 | const d = applyOffsetToDate(now, amount, unit); |
| 162 | if (type === "DATE") { | 173 | if (type === "DATE") { |
| 163 | const it = String(cfg.inputType ?? cfg.InputType ?? "").toLowerCase(); | 174 | const it = String(cfg.inputType ?? cfg.InputType ?? "").toLowerCase(); |
| @@ -185,6 +196,12 @@ function buildCreateLabelPreviewTemplate( | @@ -185,6 +196,12 @@ function buildCreateLabelPreviewTemplate( | ||
| 185 | } | 196 | } |
| 186 | el.config = cfg; | 197 | el.config = cfg; |
| 187 | } | 198 | } |
| 199 | + for (const el of tmpl.elements) { | ||
| 200 | + if (canonicalElementType(el.type) !== "NUTRITION") continue; | ||
| 201 | + const manual = nutritionByElementId[el.id] ?? {}; | ||
| 202 | + const merged = mergeNutritionManualIntoConfig({ ...(el.config as Record<string, unknown>) }, manual); | ||
| 203 | + el.config = merged as LabelElement["config"]; | ||
| 204 | + } | ||
| 188 | return tmpl; | 205 | return tmpl; |
| 189 | } | 206 | } |
| 190 | 207 | ||
| @@ -192,24 +209,32 @@ function collectTemplateDefaultValuesForSave( | @@ -192,24 +209,32 @@ function collectTemplateDefaultValuesForSave( | ||
| 192 | latest: LabelTemplateDto, | 209 | latest: LabelTemplateDto, |
| 193 | textValues: Record<string, string>, | 210 | textValues: Record<string, string>, |
| 194 | dateOffsets: Record<string, { unit: string; value: string }>, | 211 | dateOffsets: Record<string, { unit: string; value: string }>, |
| 212 | + nutritionByElementId: Record<string, Record<string, string>>, | ||
| 195 | ): Record<string, string> { | 213 | ): Record<string, string> { |
| 196 | const out: Record<string, string> = {}; | 214 | const out: Record<string, string> = {}; |
| 197 | for (const el of getDataEntryElements(latest)) { | 215 | for (const el of getDataEntryElements(latest)) { |
| 198 | const id = el.id; | 216 | const id = el.id; |
| 199 | if (isDateTimeDataEntryField(el)) { | 217 | if (isDateTimeDataEntryField(el)) { |
| 200 | const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; | 218 | const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; |
| 201 | - const amount = Number(String(pair.value).trim()); | ||
| 202 | const unit = pair.unit || "Days"; | 219 | const unit = pair.unit || "Days"; |
| 203 | - if (!Number.isFinite(amount) || String(pair.value).trim() === "") { | 220 | + const norm = normalizeLabelFormOffsetInput(pair.value); |
| 221 | + if (norm.kind === "invalid") { | ||
| 204 | out[id] = ""; | 222 | out[id] = ""; |
| 223 | + } else if (norm.kind === "zero") { | ||
| 224 | + out[id] = serializePrintInputOffset(unit, "0"); | ||
| 205 | } else { | 225 | } else { |
| 206 | - /** 落库为 JSON:App 预览/打印时按 BaseTime + 单位/数值解析,与 format 一致 */ | ||
| 207 | - out[id] = serializePrintInputOffset(unit, String(pair.value).trim()); | 226 | + out[id] = serializePrintInputOffset(unit, norm.storeValue); |
| 208 | } | 227 | } |
| 209 | } else { | 228 | } else { |
| 210 | out[id] = String(textValues[id] ?? ""); | 229 | out[id] = String(textValues[id] ?? ""); |
| 211 | } | 230 | } |
| 212 | } | 231 | } |
| 232 | + for (const nel of listNutritionElements((latest.elements ?? []) as LabelElement[])) { | ||
| 233 | + const manual = nutritionByElementId[nel.id]; | ||
| 234 | + if (!manual) continue; | ||
| 235 | + const j = nutritionDefaultValuesJsonForSave(manual); | ||
| 236 | + if (j) out[nel.id] = j; | ||
| 237 | + } | ||
| 213 | return out; | 238 | return out; |
| 214 | } | 239 | } |
| 215 | 240 | ||
| @@ -957,6 +982,8 @@ function CreateLabelDialog({ | @@ -957,6 +982,8 @@ function CreateLabelDialog({ | ||
| 957 | const [templateDateOffsets, setTemplateDateOffsets] = useState< | 982 | const [templateDateOffsets, setTemplateDateOffsets] = useState< |
| 958 | Record<string, { unit: string; value: string }> | 983 | Record<string, { unit: string; value: string }> |
| 959 | >({}); | 984 | >({}); |
| 985 | + /** NUTRITION 元素 id → 子字段(calories、fat、extra:…)手动值 */ | ||
| 986 | + const [nutritionByElementId, setNutritionByElementId] = useState<Record<string, Record<string, string>>>({}); | ||
| 960 | const [form, setForm] = useState<LabelCreateInput>({ | 987 | const [form, setForm] = useState<LabelCreateInput>({ |
| 961 | labelCode: "", | 988 | labelCode: "", |
| 962 | labelName: "", | 989 | labelName: "", |
| @@ -984,6 +1011,7 @@ function CreateLabelDialog({ | @@ -984,6 +1011,7 @@ function CreateLabelDialog({ | ||
| 984 | setSelectedTemplate(null); | 1011 | setSelectedTemplate(null); |
| 985 | setTemplateDataValues({}); | 1012 | setTemplateDataValues({}); |
| 986 | setTemplateDateOffsets({}); | 1013 | setTemplateDateOffsets({}); |
| 1014 | + setNutritionByElementId({}); | ||
| 987 | setProductCatalogCategoryId(""); | 1015 | setProductCatalogCategoryId(""); |
| 988 | }; | 1016 | }; |
| 989 | 1017 | ||
| @@ -1000,6 +1028,7 @@ function CreateLabelDialog({ | @@ -1000,6 +1028,7 @@ function CreateLabelDialog({ | ||
| 1000 | setSelectedTemplate(null); | 1028 | setSelectedTemplate(null); |
| 1001 | setTemplateDataValues({}); | 1029 | setTemplateDataValues({}); |
| 1002 | setTemplateDateOffsets({}); | 1030 | setTemplateDateOffsets({}); |
| 1031 | + setNutritionByElementId({}); | ||
| 1003 | return; | 1032 | return; |
| 1004 | } | 1033 | } |
| 1005 | let cancelled = false; | 1034 | let cancelled = false; |
| @@ -1020,11 +1049,18 @@ function CreateLabelDialog({ | @@ -1020,11 +1049,18 @@ function CreateLabelDialog({ | ||
| 1020 | } | 1049 | } |
| 1021 | setTemplateDataValues(nextValues); | 1050 | setTemplateDataValues(nextValues); |
| 1022 | setTemplateDateOffsets(nextOffsets); | 1051 | setTemplateDateOffsets(nextOffsets); |
| 1052 | + const nuts = listNutritionElements((tpl.elements ?? []) as LabelElement[]); | ||
| 1053 | + const nextNut: Record<string, Record<string, string>> = {}; | ||
| 1054 | + for (const n of nuts) { | ||
| 1055 | + nextNut[n.id] = nutritionManualValuesFromTemplateConfig(n); | ||
| 1056 | + } | ||
| 1057 | + setNutritionByElementId(nextNut); | ||
| 1023 | } catch (e: any) { | 1058 | } catch (e: any) { |
| 1024 | if (cancelled) return; | 1059 | if (cancelled) return; |
| 1025 | setSelectedTemplate(null); | 1060 | setSelectedTemplate(null); |
| 1026 | setTemplateDataValues({}); | 1061 | setTemplateDataValues({}); |
| 1027 | setTemplateDateOffsets({}); | 1062 | setTemplateDateOffsets({}); |
| 1063 | + setNutritionByElementId({}); | ||
| 1028 | toast.error("Failed to load template fields.", { | 1064 | toast.error("Failed to load template fields.", { |
| 1029 | description: e?.message ? String(e.message) : "Please select another template.", | 1065 | description: e?.message ? String(e.message) : "Please select another template.", |
| 1030 | }); | 1066 | }); |
| @@ -1058,12 +1094,16 @@ function CreateLabelDialog({ | @@ -1058,12 +1094,16 @@ function CreateLabelDialog({ | ||
| 1058 | const labelTypeId = form.labelTypeId.trim(); | 1094 | const labelTypeId = form.labelTypeId.trim(); |
| 1059 | if (!labelTypeId) return; | 1095 | if (!labelTypeId) return; |
| 1060 | const latest = await getLabelTemplate(code); | 1096 | const latest = await getLabelTemplate(code); |
| 1061 | - if (getDataEntryElements(latest).length === 0) return; | 1097 | + const dataEls = getDataEntryElements(latest); |
| 1098 | + const hasNutritionRows = | ||
| 1099 | + listNutritionElements((latest.elements ?? []) as LabelElement[]).length > 0; | ||
| 1100 | + if (dataEls.length === 0 && !hasNutritionRows) return; | ||
| 1062 | 1101 | ||
| 1063 | const inputDefaultValues = collectTemplateDefaultValuesForSave( | 1102 | const inputDefaultValues = collectTemplateDefaultValuesForSave( |
| 1064 | latest, | 1103 | latest, |
| 1065 | templateDataValues, | 1104 | templateDataValues, |
| 1066 | templateDateOffsets, | 1105 | templateDateOffsets, |
| 1106 | + nutritionByElementId, | ||
| 1067 | ); | 1107 | ); |
| 1068 | const defaultsMap = buildTemplateDefaultsMap(latest); | 1108 | const defaultsMap = buildTemplateDefaultsMap(latest); |
| 1069 | for (const productId of form.productIds) { | 1109 | for (const productId of form.productIds) { |
| @@ -1165,39 +1205,77 @@ function CreateLabelDialog({ | @@ -1165,39 +1205,77 @@ function CreateLabelDialog({ | ||
| 1165 | [selectedTemplate], | 1205 | [selectedTemplate], |
| 1166 | ); | 1206 | ); |
| 1167 | 1207 | ||
| 1208 | + const nutritionFieldBlocks = useMemo(() => { | ||
| 1209 | + if (!selectedTemplate) return [] as Array<{ el: LabelElement; spec: NutritionManualFieldSpec }>; | ||
| 1210 | + const out: Array<{ el: LabelElement; spec: NutritionManualFieldSpec }> = []; | ||
| 1211 | + for (const nel of listNutritionElements((selectedTemplate.elements ?? []) as LabelElement[])) { | ||
| 1212 | + for (const spec of listNutritionManualFieldSpecs(nel)) { | ||
| 1213 | + out.push({ el: nel, spec }); | ||
| 1214 | + } | ||
| 1215 | + } | ||
| 1216 | + return out; | ||
| 1217 | + }, [selectedTemplate]); | ||
| 1218 | + | ||
| 1219 | + const showTemplateInputColumn = | ||
| 1220 | + dataEntryElements.length > 0 || nutritionFieldBlocks.length > 0; | ||
| 1221 | + | ||
| 1168 | const previewTemplate = useMemo( | 1222 | const previewTemplate = useMemo( |
| 1169 | - () => buildCreateLabelPreviewTemplate(selectedTemplate, templateDataValues, templateDateOffsets), | ||
| 1170 | - [selectedTemplate, templateDataValues, templateDateOffsets], | 1223 | + () => |
| 1224 | + buildCreateLabelPreviewTemplate( | ||
| 1225 | + selectedTemplate, | ||
| 1226 | + templateDataValues, | ||
| 1227 | + templateDateOffsets, | ||
| 1228 | + nutritionByElementId, | ||
| 1229 | + ), | ||
| 1230 | + [selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId], | ||
| 1171 | ); | 1231 | ); |
| 1172 | const hasTemplateSelected = form.templateCode.trim().length > 0; | 1232 | const hasTemplateSelected = form.templateCode.trim().length > 0; |
| 1173 | 1233 | ||
| 1174 | return ( | 1234 | return ( |
| 1175 | <Dialog open={open} onOpenChange={onOpenChange}> | 1235 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 1176 | <DialogContent | 1236 | <DialogContent |
| 1177 | - className="overflow-hidden max-w-none" | 1237 | + className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden max-w-none gap-4 !top-5 !translate-y-0" |
| 1178 | style={{ | 1238 | style={{ |
| 1179 | width: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", | 1239 | width: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", |
| 1180 | maxWidth: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", | 1240 | maxWidth: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", |
| 1181 | - maxHeight: "86vh", | ||
| 1182 | }} | 1241 | }} |
| 1183 | > | 1242 | > |
| 1184 | - <DialogHeader> | 1243 | + <DialogHeader className="shrink-0"> |
| 1185 | <DialogTitle>Add New Label</DialogTitle> | 1244 | <DialogTitle>Add New Label</DialogTitle> |
| 1186 | <DialogDescription>Enter the details for the new label.</DialogDescription> | 1245 | <DialogDescription>Enter the details for the new label.</DialogDescription> |
| 1187 | </DialogHeader> | 1246 | </DialogHeader> |
| 1188 | 1247 | ||
| 1189 | - <div className="min-h-0 overflow-y-auto overflow-x-hidden py-2"> | 1248 | + <div |
| 1249 | + className={hasTemplateSelected ? "min-h-0 flex-none overflow-hidden py-2" : "min-h-0 flex-1 overflow-hidden py-2"} | ||
| 1250 | + style={ | ||
| 1251 | + hasTemplateSelected | ||
| 1252 | + ? { | ||
| 1253 | + height: "min(72vh, calc(100dvh - 10.5rem))", | ||
| 1254 | + maxHeight: "min(72vh, calc(100dvh - 10.5rem))", | ||
| 1255 | + } | ||
| 1256 | + : undefined | ||
| 1257 | + } | ||
| 1258 | + > | ||
| 1190 | <div | 1259 | <div |
| 1191 | - className="grid gap-3 min-w-0 items-start" | 1260 | + className="grid h-full min-h-0 min-w-0 gap-3 items-stretch" |
| 1192 | style={ | 1261 | style={ |
| 1193 | hasTemplateSelected | 1262 | hasTemplateSelected |
| 1194 | - ? { | ||
| 1195 | - gridTemplateColumns: "minmax(0, 1fr) minmax(0, 12.5rem) minmax(0, 1.55fr)", | 1263 | + ? showTemplateInputColumn |
| 1264 | + ? { | ||
| 1265 | + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 12.5rem) minmax(0, 1.55fr)", | ||
| 1266 | + gridTemplateRows: "minmax(0, 1fr)", | ||
| 1267 | + } | ||
| 1268 | + : { | ||
| 1269 | + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.55fr)", | ||
| 1270 | + gridTemplateRows: "minmax(0, 1fr)", | ||
| 1271 | + } | ||
| 1272 | + : { | ||
| 1273 | + gridTemplateColumns: "minmax(0, 1fr)", | ||
| 1274 | + gridTemplateRows: "minmax(0, 1fr)", | ||
| 1196 | } | 1275 | } |
| 1197 | - : { gridTemplateColumns: "minmax(0, 1fr)" } | ||
| 1198 | } | 1276 | } |
| 1199 | > | 1277 | > |
| 1200 | - <div className="min-w-0 rounded-lg border bg-gray-50 p-4 overflow-y-auto overflow-x-hidden w-full"> | 1278 | + <div className="min-h-0 min-w-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-gray-50 p-4 [scrollbar-gutter:stable]"> |
| 1201 | <div className="text-sm font-semibold text-gray-900">General Settings</div> | 1279 | <div className="text-sm font-semibold text-gray-900">General Settings</div> |
| 1202 | <div className="space-y-2 mt-3 mb-2"> | 1280 | <div className="space-y-2 mt-3 mb-2"> |
| 1203 | <ProductSingleSelectByCategoryField | 1281 | <ProductSingleSelectByCategoryField |
| @@ -1289,15 +1367,13 @@ function CreateLabelDialog({ | @@ -1289,15 +1367,13 @@ function CreateLabelDialog({ | ||
| 1289 | </div> | 1367 | </div> |
| 1290 | </div> | 1368 | </div> |
| 1291 | 1369 | ||
| 1292 | - {hasTemplateSelected ? ( | ||
| 1293 | - <div className="min-w-0 w-full max-w-full box-border rounded-lg border bg-gray-50 p-4 overflow-y-auto overflow-x-hidden"> | 1370 | + {hasTemplateSelected && showTemplateInputColumn ? ( |
| 1371 | + <div className="box-border min-h-0 min-w-0 w-full max-w-full overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-gray-50 p-4 [scrollbar-gutter:stable]"> | ||
| 1294 | <div className="text-sm font-semibold text-gray-900 mb-3">Template Input Data</div> | 1372 | <div className="text-sm font-semibold text-gray-900 mb-3">Template Input Data</div> |
| 1295 | {templateLoading ? ( | 1373 | {templateLoading ? ( |
| 1296 | <div className="text-sm text-gray-500">Loading template fields...</div> | 1374 | <div className="text-sm text-gray-500">Loading template fields...</div> |
| 1297 | ) : !form.templateCode.trim() ? ( | 1375 | ) : !form.templateCode.trim() ? ( |
| 1298 | <div className="text-sm text-gray-500">Select template first to load input fields.</div> | 1376 | <div className="text-sm text-gray-500">Select template first to load input fields.</div> |
| 1299 | - ) : dataEntryElements.length === 0 ? ( | ||
| 1300 | - <div className="text-sm text-gray-500">No manual input fields in this template.</div> | ||
| 1301 | ) : ( | 1377 | ) : ( |
| 1302 | <div className="space-y-3"> | 1378 | <div className="space-y-3"> |
| 1303 | {dataEntryElements.map((el) => ( | 1379 | {dataEntryElements.map((el) => ( |
| @@ -1353,9 +1429,35 @@ function CreateLabelDialog({ | @@ -1353,9 +1429,35 @@ function CreateLabelDialog({ | ||
| 1353 | )} | 1429 | )} |
| 1354 | </div> | 1430 | </div> |
| 1355 | ))} | 1431 | ))} |
| 1432 | + {nutritionFieldBlocks.length > 0 ? ( | ||
| 1433 | + <div className="pt-2 mt-2 border-t border-gray-200 space-y-3"> | ||
| 1434 | + <div className="text-xs font-semibold text-gray-700">Nutrition Facts (manual)</div> | ||
| 1435 | + {nutritionFieldBlocks.map(({ el: nel, spec }) => ( | ||
| 1436 | + <div key={`${nel.id}-${spec.subKey}`} className="space-y-1.5 w-full min-w-0"> | ||
| 1437 | + <Label className="block">{spec.columnLabel}</Label> | ||
| 1438 | + <Input | ||
| 1439 | + className="h-10 w-full min-w-0 box-border" | ||
| 1440 | + value={nutritionByElementId[nel.id]?.[spec.subKey] ?? ""} | ||
| 1441 | + onChange={(e) => | ||
| 1442 | + setNutritionByElementId((prev) => ({ | ||
| 1443 | + ...prev, | ||
| 1444 | + [nel.id]: { | ||
| 1445 | + ...(prev[nel.id] ?? {}), | ||
| 1446 | + [spec.subKey]: e.target.value, | ||
| 1447 | + }, | ||
| 1448 | + })) | ||
| 1449 | + } | ||
| 1450 | + placeholder={`Enter ${spec.columnLabel}`} | ||
| 1451 | + /> | ||
| 1452 | + </div> | ||
| 1453 | + ))} | ||
| 1454 | + </div> | ||
| 1455 | + ) : null} | ||
| 1356 | <div className="text-xs text-gray-500 pt-1 w-full min-w-0 break-words"> | 1456 | <div className="text-xs text-gray-500 pt-1 w-full min-w-0 break-words"> |
| 1357 | - Date/time fields: preview uses current time plus offset; format follows each field's | ||
| 1358 | - template setting. On save, computed values are written for the selected product. | 1457 | + Date/time fields: preview uses the current time as base; leave empty or enter 0 for "now"; |
| 1458 | + other numbers add that offset. Format follows each field's template setting. On save, values | ||
| 1459 | + are written for the selected product. Nutrition columns follow the template's nutrient list; | ||
| 1460 | + values are saved with the template defaults JSON for printing. | ||
| 1359 | </div> | 1461 | </div> |
| 1360 | </div> | 1462 | </div> |
| 1361 | )} | 1463 | )} |
| @@ -1363,10 +1465,7 @@ function CreateLabelDialog({ | @@ -1363,10 +1465,7 @@ function CreateLabelDialog({ | ||
| 1363 | ) : null} | 1465 | ) : null} |
| 1364 | 1466 | ||
| 1365 | {hasTemplateSelected ? ( | 1467 | {hasTemplateSelected ? ( |
| 1366 | - <div | ||
| 1367 | - className="min-w-0 w-full rounded-lg border bg-gray-50 p-4 overflow-y-auto overflow-x-hidden" | ||
| 1368 | - style={{ minHeight: 320 }} | ||
| 1369 | - > | 1468 | + <div className="min-h-0 min-w-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-gray-50 p-4 [scrollbar-gutter:stable]"> |
| 1370 | <div className="text-sm font-semibold text-gray-900 mb-3">Label Preview</div> | 1469 | <div className="text-sm font-semibold text-gray-900 mb-3">Label Preview</div> |
| 1371 | {previewTemplate ? ( | 1470 | {previewTemplate ? ( |
| 1372 | <div className="flex justify-center w-full min-w-0 overflow-hidden"> | 1471 | <div className="flex justify-center w-full min-w-0 overflow-hidden"> |
| @@ -1380,7 +1479,7 @@ function CreateLabelDialog({ | @@ -1380,7 +1479,7 @@ function CreateLabelDialog({ | ||
| 1380 | </div> | 1479 | </div> |
| 1381 | </div> | 1480 | </div> |
| 1382 | 1481 | ||
| 1383 | - <DialogFooter> | 1482 | + <DialogFooter className="shrink-0"> |
| 1384 | <Button variant="outline" onClick={() => onOpenChange(false)}> | 1483 | <Button variant="outline" onClick={() => onOpenChange(false)}> |
| 1385 | Cancel | 1484 | Cancel |
| 1386 | </Button> | 1485 | </Button> |
美国版/Food Labeling Management Platform/src/components/labels/LabelsView.tsx
| 1 | -import React, { useState } from 'react'; | 1 | +import React, { useCallback, useState } from 'react'; |
| 2 | import { LabelsList } from './LabelsList'; | 2 | import { LabelsList } from './LabelsList'; |
| 3 | import { LabelCategoriesView } from './LabelCategoriesView'; | 3 | import { LabelCategoriesView } from './LabelCategoriesView'; |
| 4 | import { LabelTypesView } from './LabelTypesView'; | 4 | import { LabelTypesView } from './LabelTypesView'; |
| @@ -13,6 +13,8 @@ interface LabelsViewProps { | @@ -13,6 +13,8 @@ interface LabelsViewProps { | ||
| 13 | /** Dashboard「New Label」递增;由 Labels 列表消费后应调用 onLabelCreateIntentConsumed */ | 13 | /** Dashboard「New Label」递增;由 Labels 列表消费后应调用 onLabelCreateIntentConsumed */ |
| 14 | labelCreateOpenSeq?: number; | 14 | labelCreateOpenSeq?: number; |
| 15 | onLabelCreateIntentConsumed?: () => void; | 15 | onLabelCreateIntentConsumed?: () => void; |
| 16 | + /** 标签模板新增/编辑时 true,用于布局层隐藏侧栏与顶栏 */ | ||
| 17 | + onLabelTemplateEditorLayoutOverlay?: (fullscreen: boolean) => void; | ||
| 16 | } | 18 | } |
| 17 | 19 | ||
| 18 | export function LabelsView({ | 20 | export function LabelsView({ |
| @@ -20,9 +22,18 @@ export function LabelsView({ | @@ -20,9 +22,18 @@ export function LabelsView({ | ||
| 20 | onViewChange, | 22 | onViewChange, |
| 21 | labelCreateOpenSeq = 0, | 23 | labelCreateOpenSeq = 0, |
| 22 | onLabelCreateIntentConsumed, | 24 | onLabelCreateIntentConsumed, |
| 25 | + onLabelTemplateEditorLayoutOverlay, | ||
| 23 | }: LabelsViewProps) { | 26 | }: LabelsViewProps) { |
| 24 | const [templateEditorHidesTabs, setTemplateEditorHidesTabs] = useState(false); | 27 | const [templateEditorHidesTabs, setTemplateEditorHidesTabs] = useState(false); |
| 25 | 28 | ||
| 29 | + const handleTemplateEditorOverlay = useCallback( | ||
| 30 | + (fullscreen: boolean) => { | ||
| 31 | + setTemplateEditorHidesTabs(fullscreen); | ||
| 32 | + onLabelTemplateEditorLayoutOverlay?.(fullscreen); | ||
| 33 | + }, | ||
| 34 | + [onLabelTemplateEditorLayoutOverlay], | ||
| 35 | + ); | ||
| 36 | + | ||
| 26 | const tabs: Tab[] = [ | 37 | const tabs: Tab[] = [ |
| 27 | 'Labels', | 38 | 'Labels', |
| 28 | 'Label Categories', | 39 | 'Label Categories', |
| @@ -89,7 +100,7 @@ export function LabelsView({ | @@ -89,7 +100,7 @@ export function LabelsView({ | ||
| 89 | )} | 100 | )} |
| 90 | {currentView === 'Label Templates' && ( | 101 | {currentView === 'Label Templates' && ( |
| 91 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | 102 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| 92 | - <LabelTemplatesView onTemplateEditorOverlayChange={setTemplateEditorHidesTabs} /> | 103 | + <LabelTemplatesView onTemplateEditorOverlayChange={handleTemplateEditorOverlay} /> |
| 93 | </div> | 104 | </div> |
| 94 | )} | 105 | )} |
| 95 | {currentView === 'Multiple Options' && ( | 106 | {currentView === 'Multiple Options' && ( |
美国版/Food Labeling Management Platform/src/components/layout/Layout.tsx
| @@ -10,31 +10,50 @@ interface LayoutProps { | @@ -10,31 +10,50 @@ interface LayoutProps { | ||
| 10 | setCurrentView: (view: string) => void; | 10 | setCurrentView: (view: string) => void; |
| 11 | menus?: CurrentUserMenuNodeDto[]; | 11 | menus?: CurrentUserMenuNodeDto[]; |
| 12 | onLogout?: () => void; | 12 | onLogout?: () => void; |
| 13 | + /** 标签模板编辑器全屏:隐藏侧栏与顶栏,主内容占满视口 */ | ||
| 14 | + hideAppChrome?: boolean; | ||
| 13 | } | 15 | } |
| 14 | 16 | ||
| 15 | -export function Layout({ children, currentView, setCurrentView, menus, onLogout }: LayoutProps) { | 17 | +export function Layout({ |
| 18 | + children, | ||
| 19 | + currentView, | ||
| 20 | + setCurrentView, | ||
| 21 | + menus, | ||
| 22 | + onLogout, | ||
| 23 | + hideAppChrome = false, | ||
| 24 | +}: LayoutProps) { | ||
| 16 | return ( | 25 | return ( |
| 17 | <div className="flex h-screen bg-gray-50 overflow-hidden font-sans"> | 26 | <div className="flex h-screen bg-gray-50 overflow-hidden font-sans"> |
| 18 | - <Sidebar currentView={currentView} setCurrentView={setCurrentView} menus={menus} onLogout={onLogout} /> | ||
| 19 | - <div className="flex-1 flex flex-col min-w-0 overflow-hidden"> | ||
| 20 | - <Header title={currentView} onSettingsClick={() => setCurrentView('Support')} /> | ||
| 21 | - <div className="px-8 mt-8 shrink-0"> | ||
| 22 | - <nav className="flex items-center gap-2 text-sm font-normal" aria-label="Breadcrumb"> | ||
| 23 | - <button | ||
| 24 | - type="button" | ||
| 25 | - onClick={() => setCurrentView('Dashboard')} | ||
| 26 | - className="text-gray-500 hover:text-gray-700 transition-colors" | ||
| 27 | - > | ||
| 28 | - Home | ||
| 29 | - </button> | ||
| 30 | - <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" /> | ||
| 31 | - <span style={{ color: 'rgb(43, 50, 143)' }}>{currentView}</span> | ||
| 32 | - </nav> | ||
| 33 | - </div> | ||
| 34 | - <main className="min-h-0 flex-1 overflow-y-auto p-8"> | ||
| 35 | - <div className="h-full min-h-0 w-full"> | ||
| 36 | - {children} | ||
| 37 | - </div> | 27 | + {!hideAppChrome && ( |
| 28 | + <Sidebar currentView={currentView} setCurrentView={setCurrentView} menus={menus} onLogout={onLogout} /> | ||
| 29 | + )} | ||
| 30 | + <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> | ||
| 31 | + {!hideAppChrome && ( | ||
| 32 | + <> | ||
| 33 | + <Header title={currentView} onSettingsClick={() => setCurrentView('Support')} /> | ||
| 34 | + <div className="mt-8 shrink-0 px-8"> | ||
| 35 | + <nav className="flex items-center gap-2 text-sm font-normal" aria-label="Breadcrumb"> | ||
| 36 | + <button | ||
| 37 | + type="button" | ||
| 38 | + onClick={() => setCurrentView('Dashboard')} | ||
| 39 | + className="text-gray-500 transition-colors hover:text-gray-700" | ||
| 40 | + > | ||
| 41 | + Home | ||
| 42 | + </button> | ||
| 43 | + <ChevronRight className="h-4 w-4 shrink-0 text-gray-500" /> | ||
| 44 | + <span style={{ color: 'rgb(43, 50, 143)' }}>{currentView}</span> | ||
| 45 | + </nav> | ||
| 46 | + </div> | ||
| 47 | + </> | ||
| 48 | + )} | ||
| 49 | + <main | ||
| 50 | + className={ | ||
| 51 | + hideAppChrome | ||
| 52 | + ? 'flex min-h-0 flex-1 flex-col overflow-hidden p-0' | ||
| 53 | + : 'min-h-0 flex-1 overflow-y-auto p-8' | ||
| 54 | + } | ||
| 55 | + > | ||
| 56 | + <div className="flex h-full min-h-0 w-full flex-col">{children}</div> | ||
| 38 | </main> | 57 | </main> |
| 39 | </div> | 58 | </div> |
| 40 | </div> | 59 | </div> |
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
| 1 | import React, { useEffect, useMemo, useRef, useState } from "react"; | 1 | import React, { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { Edit, MapPin, MoreHorizontal, Trash2 } from "lucide-react"; | 2 | import { Edit, MapPin, MoreHorizontal, Trash2 } from "lucide-react"; |
| 3 | import { Button } from "../ui/button"; | 3 | import { Button } from "../ui/button"; |
| 4 | +import { Checkbox } from "../ui/checkbox"; | ||
| 4 | import { Input } from "../ui/input"; | 5 | import { Input } from "../ui/input"; |
| 5 | import { | 6 | import { |
| 6 | Table, | 7 | Table, |
| @@ -30,7 +31,6 @@ import { Badge } from "../ui/badge"; | @@ -30,7 +31,6 @@ import { Badge } from "../ui/badge"; | ||
| 30 | import { Switch } from "../ui/switch"; | 31 | import { Switch } from "../ui/switch"; |
| 31 | import { toast } from "sonner"; | 32 | import { toast } from "sonner"; |
| 32 | import { skipCountForPage } from "../../lib/paginationQuery"; | 33 | import { skipCountForPage } from "../../lib/paginationQuery"; |
| 33 | -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; | ||
| 34 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | 34 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; |
| 35 | import { | 35 | import { |
| 36 | Pagination, | 36 | Pagination, |
| @@ -40,12 +40,22 @@ import { | @@ -40,12 +40,22 @@ import { | ||
| 40 | PaginationNext, | 40 | PaginationNext, |
| 41 | PaginationPrevious, | 41 | PaginationPrevious, |
| 42 | } from "../ui/pagination"; | 42 | } from "../ui/pagination"; |
| 43 | -import { createLocation, deleteLocation, getLocations, updateLocation } from "../../services/locationService"; | 43 | +import { |
| 44 | + createLocation, | ||
| 45 | + deleteLocation, | ||
| 46 | + downloadLocationImportTemplate, | ||
| 47 | + exportLocationsExcel, | ||
| 48 | + getLocations, | ||
| 49 | + importLocationsBatch, | ||
| 50 | + updateLocation, | ||
| 51 | +} from "../../services/locationService"; | ||
| 44 | import { getPartners } from "../../services/partnerService"; | 52 | import { getPartners } from "../../services/partnerService"; |
| 45 | import { getGroups } from "../../services/groupService"; | 53 | import { getGroups } from "../../services/groupService"; |
| 46 | import type { LocationCreateInput, LocationDto } from "../../types/location"; | 54 | import type { LocationCreateInput, LocationDto } from "../../types/location"; |
| 47 | import type { GroupListItem } from "../../types/group"; | 55 | import type { GroupListItem } from "../../types/group"; |
| 48 | import type { PartnerListItem } from "../../types/partner"; | 56 | import type { PartnerListItem } from "../../types/partner"; |
| 57 | +import { BatchImportDialog } from "../bulk/batch-import-dialog"; | ||
| 58 | +import { LocationBulkEditDialog } from "./location-bulk-edit-dialog"; | ||
| 49 | 59 | ||
| 50 | const LOCATION_PG_NONE = "__none__"; | 60 | const LOCATION_PG_NONE = "__none__"; |
| 51 | 61 | ||
| @@ -137,6 +147,12 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -137,6 +147,12 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 137 | const [total, setTotal] = useState(0); | 147 | const [total, setTotal] = useState(0); |
| 138 | const [refreshSeq, setRefreshSeq] = useState(0); | 148 | const [refreshSeq, setRefreshSeq] = useState(0); |
| 139 | const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); | 149 | const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); |
| 150 | + const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set()); | ||
| 151 | + const [bulkImportOpen, setBulkImportOpen] = useState(false); | ||
| 152 | + const [bulkEditOpen, setBulkEditOpen] = useState(false); | ||
| 153 | + const [bulkEditSeed, setBulkEditSeed] = useState<LocationDto[]>([]); | ||
| 154 | + const [tmplDownloading, setTmplDownloading] = useState(false); | ||
| 155 | + const [excelExporting, setExcelExporting] = useState(false); | ||
| 140 | 156 | ||
| 141 | const [keyword, setKeyword] = useState(""); | 157 | const [keyword, setKeyword] = useState(""); |
| 142 | const [partner, setPartner] = useState<string>("all"); | 158 | const [partner, setPartner] = useState<string>("all"); |
| @@ -158,6 +174,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -158,6 +174,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 158 | }; | 174 | }; |
| 159 | }, [keyword]); | 175 | }, [keyword]); |
| 160 | 176 | ||
| 177 | + const listKeyword = useMemo( | ||
| 178 | + () => (locationPick !== "all" ? locationPick : debouncedKeyword.trim()), | ||
| 179 | + [locationPick, debouncedKeyword], | ||
| 180 | + ); | ||
| 181 | + | ||
| 161 | // Options derived from current result set (no dedicated endpoints provided in doc). | 182 | // Options derived from current result set (no dedicated endpoints provided in doc). |
| 162 | const partnerOptions = useMemo(() => { | 183 | const partnerOptions = useMemo(() => { |
| 163 | const s = new Set<string>(); | 184 | const s = new Set<string>(); |
| @@ -203,12 +224,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -203,12 +224,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 203 | setLoading(true); | 224 | setLoading(true); |
| 204 | try { | 225 | try { |
| 205 | const skipCount = skipCountForPage(pageIndex); | 226 | const skipCount = skipCountForPage(pageIndex); |
| 206 | - const effectiveKeyword = locationPick !== "all" ? locationPick : debouncedKeyword; | ||
| 207 | const res = await getLocations( | 227 | const res = await getLocations( |
| 208 | { | 228 | { |
| 209 | skipCount, | 229 | skipCount, |
| 210 | maxResultCount: pageSize, | 230 | maxResultCount: pageSize, |
| 211 | - keyword: effectiveKeyword || undefined, | 231 | + keyword: listKeyword || undefined, |
| 212 | partner: partner !== "all" ? partner : undefined, | 232 | partner: partner !== "all" ? partner : undefined, |
| 213 | groupName: groupName !== "all" ? groupName : undefined, | 233 | groupName: groupName !== "all" ? groupName : undefined, |
| 214 | }, | 234 | }, |
| @@ -231,7 +251,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -231,7 +251,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 231 | 251 | ||
| 232 | run(); | 252 | run(); |
| 233 | return () => abortRef.current?.abort(); | 253 | return () => abortRef.current?.abort(); |
| 234 | - }, [debouncedKeyword, partner, groupName, locationPick, pageIndex, pageSize, refreshSeq]); | 254 | + }, [listKeyword, partner, groupName, locationPick, pageIndex, pageSize, refreshSeq]); |
| 255 | + | ||
| 256 | + useEffect(() => { | ||
| 257 | + setSelectedIds(new Set()); | ||
| 258 | + }, [debouncedKeyword, partner, groupName, locationPick, pageIndex]); | ||
| 235 | 259 | ||
| 236 | const refreshList = () => setRefreshSeq((x) => x + 1); | 260 | const refreshList = () => setRefreshSeq((x) => x + 1); |
| 237 | 261 | ||
| @@ -295,36 +319,54 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -295,36 +319,54 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 295 | </SelectContent> | 319 | </SelectContent> |
| 296 | </Select> | 320 | </Select> |
| 297 | <div className="flex-1" /> | 321 | <div className="flex-1" /> |
| 298 | - <Tooltip> | ||
| 299 | - <TooltipTrigger asChild> | ||
| 300 | - <span> | ||
| 301 | - <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> | ||
| 302 | - Bulk Import | ||
| 303 | - </Button> | ||
| 304 | - </span> | ||
| 305 | - </TooltipTrigger> | ||
| 306 | - <TooltipContent>Not supported yet</TooltipContent> | ||
| 307 | - </Tooltip> | ||
| 308 | - <Tooltip> | ||
| 309 | - <TooltipTrigger asChild> | ||
| 310 | - <span> | ||
| 311 | - <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> | ||
| 312 | - Bulk Export | ||
| 313 | - </Button> | ||
| 314 | - </span> | ||
| 315 | - </TooltipTrigger> | ||
| 316 | - <TooltipContent>Not supported yet</TooltipContent> | ||
| 317 | - </Tooltip> | ||
| 318 | - <Tooltip> | ||
| 319 | - <TooltipTrigger asChild> | ||
| 320 | - <span> | ||
| 321 | - <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> | ||
| 322 | - Bulk Edit | ||
| 323 | - </Button> | ||
| 324 | - </span> | ||
| 325 | - </TooltipTrigger> | ||
| 326 | - <TooltipContent>Not supported yet</TooltipContent> | ||
| 327 | - </Tooltip> | 322 | + <Button |
| 323 | + type="button" | ||
| 324 | + variant="outline" | ||
| 325 | + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0" | ||
| 326 | + onClick={() => setBulkImportOpen(true)} | ||
| 327 | + > | ||
| 328 | + Bulk Import | ||
| 329 | + </Button> | ||
| 330 | + <Button | ||
| 331 | + type="button" | ||
| 332 | + variant="outline" | ||
| 333 | + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0" | ||
| 334 | + disabled={excelExporting} | ||
| 335 | + onClick={async () => { | ||
| 336 | + setExcelExporting(true); | ||
| 337 | + try { | ||
| 338 | + await exportLocationsExcel({ | ||
| 339 | + keyword: listKeyword || undefined, | ||
| 340 | + partner: partner !== "all" ? partner : undefined, | ||
| 341 | + groupName: groupName !== "all" ? groupName : undefined, | ||
| 342 | + }); | ||
| 343 | + toast.success("Export started", { description: "Your browser should download the Excel file." }); | ||
| 344 | + } catch (e: unknown) { | ||
| 345 | + const msg = e instanceof Error ? e.message : "Please try again."; | ||
| 346 | + toast.error("Export failed", { description: msg }); | ||
| 347 | + } finally { | ||
| 348 | + setExcelExporting(false); | ||
| 349 | + } | ||
| 350 | + }} | ||
| 351 | + > | ||
| 352 | + {excelExporting ? "Exporting…" : "Bulk Export"} | ||
| 353 | + </Button> | ||
| 354 | + <Button | ||
| 355 | + type="button" | ||
| 356 | + variant="outline" | ||
| 357 | + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0" | ||
| 358 | + onClick={() => { | ||
| 359 | + const seed = locations.filter((l) => selectedIds.has(l.id)); | ||
| 360 | + if (seed.length === 0) { | ||
| 361 | + toast.error("No rows selected", { description: "Use the checkboxes on the left, then open Bulk Edit." }); | ||
| 362 | + return; | ||
| 363 | + } | ||
| 364 | + setBulkEditSeed(seed); | ||
| 365 | + setBulkEditOpen(true); | ||
| 366 | + }} | ||
| 367 | + > | ||
| 368 | + Bulk Edit | ||
| 369 | + </Button> | ||
| 328 | <Button | 370 | <Button |
| 329 | className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" | 371 | className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" |
| 330 | onClick={() => setIsCreateDialogOpen(true)} | 372 | onClick={() => setIsCreateDialogOpen(true)} |
| @@ -343,6 +385,16 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -343,6 +385,16 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 343 | <Table> | 385 | <Table> |
| 344 | <TableHeader> | 386 | <TableHeader> |
| 345 | <TableRow className="bg-gray-100 hover:bg-gray-100"> | 387 | <TableRow className="bg-gray-100 hover:bg-gray-100"> |
| 388 | + <TableHead className="text-gray-900 font-bold border-r w-12 shrink-0 text-center pl-2 pr-4"> | ||
| 389 | + <Checkbox | ||
| 390 | + checked={locations.length > 0 && locations.every((l) => selectedIds.has(l.id))} | ||
| 391 | + onCheckedChange={(c) => { | ||
| 392 | + if (c === true) setSelectedIds(new Set(locations.map((l) => l.id))); | ||
| 393 | + else setSelectedIds(new Set()); | ||
| 394 | + }} | ||
| 395 | + aria-label="Select all on page" | ||
| 396 | + /> | ||
| 397 | + </TableHead> | ||
| 346 | <TableHead className="text-gray-900 font-bold border-r">Company</TableHead> | 398 | <TableHead className="text-gray-900 font-bold border-r">Company</TableHead> |
| 347 | <TableHead className="text-gray-900 font-bold border-r">Region</TableHead> | 399 | <TableHead className="text-gray-900 font-bold border-r">Region</TableHead> |
| 348 | <TableHead className="text-gray-900 font-bold border-r">Location ID</TableHead> | 400 | <TableHead className="text-gray-900 font-bold border-r">Location ID</TableHead> |
| @@ -362,19 +414,33 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -362,19 +414,33 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 362 | <TableBody> | 414 | <TableBody> |
| 363 | {loading ? ( | 415 | {loading ? ( |
| 364 | <TableRow> | 416 | <TableRow> |
| 365 | - <TableCell colSpan={14} className="text-center text-sm text-gray-500 py-10"> | 417 | + <TableCell colSpan={15} className="text-center text-sm text-gray-500 py-10"> |
| 366 | Loading... | 418 | Loading... |
| 367 | </TableCell> | 419 | </TableCell> |
| 368 | </TableRow> | 420 | </TableRow> |
| 369 | ) : locations.length === 0 ? ( | 421 | ) : locations.length === 0 ? ( |
| 370 | <TableRow> | 422 | <TableRow> |
| 371 | - <TableCell colSpan={14} className="text-center text-sm text-gray-500 py-10"> | 423 | + <TableCell colSpan={15} className="text-center text-sm text-gray-500 py-10"> |
| 372 | No results. | 424 | No results. |
| 373 | </TableCell> | 425 | </TableCell> |
| 374 | </TableRow> | 426 | </TableRow> |
| 375 | ) : ( | 427 | ) : ( |
| 376 | locations.map((loc) => ( | 428 | locations.map((loc) => ( |
| 377 | <TableRow key={loc.id}> | 429 | <TableRow key={loc.id}> |
| 430 | + <TableCell className="border-r w-12 shrink-0 text-center pl-2 pr-4"> | ||
| 431 | + <Checkbox | ||
| 432 | + checked={selectedIds.has(loc.id)} | ||
| 433 | + onCheckedChange={(c) => { | ||
| 434 | + setSelectedIds((prev) => { | ||
| 435 | + const n = new Set(prev); | ||
| 436 | + if (c === true) n.add(loc.id); | ||
| 437 | + else n.delete(loc.id); | ||
| 438 | + return n; | ||
| 439 | + }); | ||
| 440 | + }} | ||
| 441 | + aria-label="Select row" | ||
| 442 | + /> | ||
| 443 | + </TableCell> | ||
| 378 | <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.partner)}</TableCell> | 444 | <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.partner)}</TableCell> |
| 379 | <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.groupName)}</TableCell> | 445 | <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.groupName)}</TableCell> |
| 380 | <TableCell className="border-r font-numeric text-gray-600">{toDisplay(loc.locationCode ?? loc.id)}</TableCell> | 446 | <TableCell className="border-r font-numeric text-gray-600">{toDisplay(loc.locationCode ?? loc.id)}</TableCell> |
| @@ -537,6 +603,41 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | @@ -537,6 +603,41 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { | ||
| 537 | refreshList(); | 603 | refreshList(); |
| 538 | }} | 604 | }} |
| 539 | /> | 605 | /> |
| 606 | + | ||
| 607 | + <BatchImportDialog | ||
| 608 | + open={bulkImportOpen} | ||
| 609 | + onOpenChange={setBulkImportOpen} | ||
| 610 | + title="Bulk import locations" | ||
| 611 | + description="Upload an .xlsx file. Use the official template for correct column headers." | ||
| 612 | + downloadingTemplate={tmplDownloading} | ||
| 613 | + onDownloadTemplate={async () => { | ||
| 614 | + setTmplDownloading(true); | ||
| 615 | + try { | ||
| 616 | + await downloadLocationImportTemplate(); | ||
| 617 | + toast.success("Template downloaded."); | ||
| 618 | + } catch (e: unknown) { | ||
| 619 | + const msg = e instanceof Error ? e.message : "Download failed."; | ||
| 620 | + toast.error("Template download failed", { description: msg }); | ||
| 621 | + } finally { | ||
| 622 | + setTmplDownloading(false); | ||
| 623 | + } | ||
| 624 | + }} | ||
| 625 | + onImportFile={async (file) => { | ||
| 626 | + const r = await importLocationsBatch(file); | ||
| 627 | + refreshList(); | ||
| 628 | + return { successCount: r.successCount, failCount: r.failCount }; | ||
| 629 | + }} | ||
| 630 | + /> | ||
| 631 | + | ||
| 632 | + <LocationBulkEditDialog | ||
| 633 | + open={bulkEditOpen} | ||
| 634 | + onOpenChange={setBulkEditOpen} | ||
| 635 | + seed={bulkEditSeed} | ||
| 636 | + onSaved={() => { | ||
| 637 | + setSelectedIds(new Set()); | ||
| 638 | + refreshList(); | ||
| 639 | + }} | ||
| 640 | + /> | ||
| 540 | </> | 641 | </> |
| 541 | ); | 642 | ); |
| 542 | 643 |
美国版/Food Labeling Management Platform/src/components/locations/location-bulk-edit-dialog.tsx
0 → 100644
| 1 | +import React, { useEffect, useMemo, useState } from "react"; | ||
| 2 | +import { Button } from "../ui/button"; | ||
| 3 | +import { Input } from "../ui/input"; | ||
| 4 | +import { Switch } from "../ui/switch"; | ||
| 5 | +import { | ||
| 6 | + Dialog, | ||
| 7 | + DialogContent, | ||
| 8 | + DialogDescription, | ||
| 9 | + DialogHeader, | ||
| 10 | + DialogTitle, | ||
| 11 | +} from "../ui/dialog"; | ||
| 12 | +import { toast } from "sonner"; | ||
| 13 | +import { ApiError } from "../../lib/apiClient"; | ||
| 14 | +import { updateLocationsBulk, type LocationBulkUpdateItemVo } from "../../services/locationService"; | ||
| 15 | +import type { LocationDto } from "../../types/location"; | ||
| 16 | + | ||
| 17 | +const ZERO = "00000000-0000-0000-0000-000000000000"; | ||
| 18 | + | ||
| 19 | +function isValidBulkId(id: string): boolean { | ||
| 20 | + const s = (id ?? "").trim(); | ||
| 21 | + if (!s) return false; | ||
| 22 | + return s.toLowerCase() !== ZERO; | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +export type LocationBulkEditDialogProps = { | ||
| 26 | + open: boolean; | ||
| 27 | + onOpenChange: (open: boolean) => void; | ||
| 28 | + /** 从列表勾选带入的行 */ | ||
| 29 | + seed: LocationDto[]; | ||
| 30 | + onSaved: () => void; | ||
| 31 | +}; | ||
| 32 | + | ||
| 33 | +type RowState = LocationBulkUpdateItemVo & { locationCodeReadonly: string }; | ||
| 34 | + | ||
| 35 | +function locToRow(loc: LocationDto): RowState { | ||
| 36 | + return { | ||
| 37 | + id: loc.id, | ||
| 38 | + locationCodeReadonly: (loc.locationCode ?? loc.id ?? "").trim(), | ||
| 39 | + partner: loc.partner ?? "", | ||
| 40 | + groupName: loc.groupName ?? "", | ||
| 41 | + locationName: (loc.locationName ?? "").trim() || "", | ||
| 42 | + street: loc.street ?? "", | ||
| 43 | + city: loc.city ?? "", | ||
| 44 | + stateCode: loc.stateCode ?? "", | ||
| 45 | + country: loc.country ?? "", | ||
| 46 | + zipCode: loc.zipCode ?? "", | ||
| 47 | + phone: loc.phone ?? "", | ||
| 48 | + email: loc.email ?? "", | ||
| 49 | + latitude: loc.latitude ?? null, | ||
| 50 | + longitude: loc.longitude ?? null, | ||
| 51 | + state: loc.state !== false, | ||
| 52 | + }; | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +function emptyPadRow(): RowState { | ||
| 56 | + return { | ||
| 57 | + id: "", | ||
| 58 | + locationCodeReadonly: "", | ||
| 59 | + partner: "", | ||
| 60 | + groupName: "", | ||
| 61 | + locationName: "", | ||
| 62 | + street: "", | ||
| 63 | + city: "", | ||
| 64 | + stateCode: "", | ||
| 65 | + country: "", | ||
| 66 | + zipCode: "", | ||
| 67 | + phone: "", | ||
| 68 | + email: "", | ||
| 69 | + latitude: null, | ||
| 70 | + longitude: null, | ||
| 71 | + state: true, | ||
| 72 | + }; | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: LocationBulkEditDialogProps) { | ||
| 76 | + const [rows, setRows] = useState<RowState[]>([]); | ||
| 77 | + const [saving, setSaving] = useState(false); | ||
| 78 | + | ||
| 79 | + const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]); | ||
| 80 | + | ||
| 81 | + useEffect(() => { | ||
| 82 | + if (!open) return; | ||
| 83 | + const data = seed.map(locToRow); | ||
| 84 | + const pad = Math.max(0, minRows - data.length); | ||
| 85 | + setRows([...data, ...Array.from({ length: pad }, () => emptyPadRow())]); | ||
| 86 | + }, [open, seed, minRows]); | ||
| 87 | + | ||
| 88 | + const updateRow = (idx: number, patch: Partial<RowState>) => { | ||
| 89 | + setRows((prev) => { | ||
| 90 | + const next = [...prev]; | ||
| 91 | + next[idx] = { ...next[idx], ...patch }; | ||
| 92 | + return next; | ||
| 93 | + }); | ||
| 94 | + }; | ||
| 95 | + | ||
| 96 | + const removeRow = (idx: number) => { | ||
| 97 | + setRows((prev) => { | ||
| 98 | + if (prev.length <= 1) return prev; | ||
| 99 | + return prev.filter((_, i) => i !== idx); | ||
| 100 | + }); | ||
| 101 | + }; | ||
| 102 | + | ||
| 103 | + const handleSave = async () => { | ||
| 104 | + const items: LocationBulkUpdateItemVo[] = rows | ||
| 105 | + .filter((r) => isValidBulkId(r.id)) | ||
| 106 | + .map((r) => ({ | ||
| 107 | + id: r.id.trim(), | ||
| 108 | + partner: r.partner?.trim() || null, | ||
| 109 | + groupName: r.groupName?.trim() || null, | ||
| 110 | + locationName: r.locationName.trim(), | ||
| 111 | + street: r.street?.trim() || null, | ||
| 112 | + city: r.city?.trim() || null, | ||
| 113 | + stateCode: r.stateCode?.trim() || null, | ||
| 114 | + country: r.country?.trim() || null, | ||
| 115 | + zipCode: r.zipCode?.trim() || null, | ||
| 116 | + phone: r.phone?.trim() || null, | ||
| 117 | + email: r.email?.trim() || null, | ||
| 118 | + latitude: r.latitude, | ||
| 119 | + longitude: r.longitude, | ||
| 120 | + state: r.state !== false, | ||
| 121 | + })); | ||
| 122 | + | ||
| 123 | + if (items.length === 0) { | ||
| 124 | + toast.error("No valid rows", { description: "Select locations in the list or keep rows with valid IDs." }); | ||
| 125 | + return; | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + setSaving(true); | ||
| 129 | + try { | ||
| 130 | + const res = await updateLocationsBulk({ items }); | ||
| 131 | + toast.success("Bulk update finished", { | ||
| 132 | + description: `Success: ${res.successCount}, failed: ${res.failCount}`, | ||
| 133 | + }); | ||
| 134 | + if (res.errors?.length) { | ||
| 135 | + const preview = res.errors | ||
| 136 | + .slice(0, 5) | ||
| 137 | + .map((e) => `Row ${e.rowNumber ?? "?"}: ${e.message ?? ""}`) | ||
| 138 | + .join("\n"); | ||
| 139 | + toast.message("Errors (first 5)", { description: preview }); | ||
| 140 | + } | ||
| 141 | + onSaved(); | ||
| 142 | + onOpenChange(false); | ||
| 143 | + } catch (e) { | ||
| 144 | + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed."; | ||
| 145 | + toast.error("Bulk save failed", { description: msg }); | ||
| 146 | + } finally { | ||
| 147 | + setSaving(false); | ||
| 148 | + } | ||
| 149 | + }; | ||
| 150 | + | ||
| 151 | + return ( | ||
| 152 | + <Dialog open={open} onOpenChange={onOpenChange}> | ||
| 153 | + <DialogContent className="max-w-[min(96vw,1200px)] w-full max-h-[90vh] flex flex-col gap-0 p-0"> | ||
| 154 | + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0"> | ||
| 155 | + <Button type="button" variant="outline" className="shrink-0" onClick={() => onOpenChange(false)}> | ||
| 156 | + Back | ||
| 157 | + </Button> | ||
| 158 | + <DialogHeader className="flex-1 text-center space-y-0 py-0"> | ||
| 159 | + <DialogTitle className="text-base">Location bulk edit</DialogTitle> | ||
| 160 | + <DialogDescription className="sr-only">Edit multiple locations and save all.</DialogDescription> | ||
| 161 | + </DialogHeader> | ||
| 162 | + <Button | ||
| 163 | + type="button" | ||
| 164 | + className="bg-green-600 hover:bg-green-700 text-white shrink-0" | ||
| 165 | + disabled={saving} | ||
| 166 | + onClick={() => void handleSave()} | ||
| 167 | + > | ||
| 168 | + {saving ? "Saving…" : "Save All"} | ||
| 169 | + </Button> | ||
| 170 | + </div> | ||
| 171 | + <div className="overflow-auto flex-1 min-h-0 px-2 py-3"> | ||
| 172 | + <table className="w-full text-xs border-collapse border border-gray-200"> | ||
| 173 | + <thead className="bg-gray-100 sticky top-0 z-10"> | ||
| 174 | + <tr> | ||
| 175 | + <th className="border p-1 w-8" /> | ||
| 176 | + <th className="border p-1 whitespace-nowrap">Location ID</th> | ||
| 177 | + <th className="border p-1 whitespace-nowrap">Company</th> | ||
| 178 | + <th className="border p-1 whitespace-nowrap">Region</th> | ||
| 179 | + <th className="border p-1 whitespace-nowrap">Location Name *</th> | ||
| 180 | + <th className="border p-1 whitespace-nowrap">Street</th> | ||
| 181 | + <th className="border p-1 whitespace-nowrap">City</th> | ||
| 182 | + <th className="border p-1 whitespace-nowrap">State</th> | ||
| 183 | + <th className="border p-1 whitespace-nowrap">Country</th> | ||
| 184 | + <th className="border p-1 whitespace-nowrap">Zip</th> | ||
| 185 | + <th className="border p-1 whitespace-nowrap">Phone</th> | ||
| 186 | + <th className="border p-1 whitespace-nowrap">Email</th> | ||
| 187 | + <th className="border p-1 whitespace-nowrap">Lat</th> | ||
| 188 | + <th className="border p-1 whitespace-nowrap">Lng</th> | ||
| 189 | + <th className="border p-1 whitespace-nowrap">Active</th> | ||
| 190 | + </tr> | ||
| 191 | + </thead> | ||
| 192 | + <tbody> | ||
| 193 | + {rows.map((r, idx) => ( | ||
| 194 | + <tr key={`${r.id || "new"}-${idx}`} className="bg-white"> | ||
| 195 | + <td className="border p-0 align-middle text-center"> | ||
| 196 | + <div className="flex flex-col items-center gap-1 py-1"> | ||
| 197 | + <span className="text-[10px] text-gray-500">{idx + 1}</span> | ||
| 198 | + <Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-1" onClick={() => removeRow(idx)}> | ||
| 199 | + × | ||
| 200 | + </Button> | ||
| 201 | + </div> | ||
| 202 | + </td> | ||
| 203 | + <td className="border p-1 align-top"> | ||
| 204 | + <Input | ||
| 205 | + className="h-7 text-xs min-w-[100px]" | ||
| 206 | + value={r.locationCodeReadonly} | ||
| 207 | + readOnly | ||
| 208 | + title="Location ID is not changed in bulk edit" | ||
| 209 | + /> | ||
| 210 | + </td> | ||
| 211 | + <td className="border p-1 align-top"> | ||
| 212 | + <Input className="h-7 text-xs min-w-[80px]" value={r.partner ?? ""} onChange={(e) => updateRow(idx, { partner: e.target.value })} /> | ||
| 213 | + </td> | ||
| 214 | + <td className="border p-1 align-top"> | ||
| 215 | + <Input className="h-7 text-xs min-w-[80px]" value={r.groupName ?? ""} onChange={(e) => updateRow(idx, { groupName: e.target.value })} /> | ||
| 216 | + </td> | ||
| 217 | + <td className="border p-1 align-top"> | ||
| 218 | + <Input className="h-7 text-xs min-w-[100px]" value={r.locationName} onChange={(e) => updateRow(idx, { locationName: e.target.value })} /> | ||
| 219 | + </td> | ||
| 220 | + <td className="border p-1 align-top"> | ||
| 221 | + <Input className="h-7 text-xs min-w-[80px]" value={r.street ?? ""} onChange={(e) => updateRow(idx, { street: e.target.value })} /> | ||
| 222 | + </td> | ||
| 223 | + <td className="border p-1 align-top"> | ||
| 224 | + <Input className="h-7 text-xs min-w-[72px]" value={r.city ?? ""} onChange={(e) => updateRow(idx, { city: e.target.value })} /> | ||
| 225 | + </td> | ||
| 226 | + <td className="border p-1 align-top"> | ||
| 227 | + <Input className="h-7 text-xs min-w-[48px]" value={r.stateCode ?? ""} onChange={(e) => updateRow(idx, { stateCode: e.target.value })} /> | ||
| 228 | + </td> | ||
| 229 | + <td className="border p-1 align-top"> | ||
| 230 | + <Input className="h-7 text-xs min-w-[56px]" value={r.country ?? ""} onChange={(e) => updateRow(idx, { country: e.target.value })} /> | ||
| 231 | + </td> | ||
| 232 | + <td className="border p-1 align-top"> | ||
| 233 | + <Input className="h-7 text-xs min-w-[56px]" value={r.zipCode ?? ""} onChange={(e) => updateRow(idx, { zipCode: e.target.value })} /> | ||
| 234 | + </td> | ||
| 235 | + <td className="border p-1 align-top"> | ||
| 236 | + <Input className="h-7 text-xs min-w-[88px]" value={r.phone ?? ""} onChange={(e) => updateRow(idx, { phone: e.target.value })} /> | ||
| 237 | + </td> | ||
| 238 | + <td className="border p-1 align-top"> | ||
| 239 | + <Input className="h-7 text-xs min-w-[120px]" value={r.email ?? ""} onChange={(e) => updateRow(idx, { email: e.target.value })} /> | ||
| 240 | + </td> | ||
| 241 | + <td className="border p-1 align-top"> | ||
| 242 | + <Input | ||
| 243 | + className="h-7 text-xs min-w-[64px]" | ||
| 244 | + value={r.latitude === null || r.latitude === undefined ? "" : String(r.latitude)} | ||
| 245 | + onChange={(e) => { | ||
| 246 | + const v = e.target.value.trim(); | ||
| 247 | + updateRow(idx, { latitude: v === "" ? null : Number(v) }); | ||
| 248 | + }} | ||
| 249 | + /> | ||
| 250 | + </td> | ||
| 251 | + <td className="border p-1 align-top"> | ||
| 252 | + <Input | ||
| 253 | + className="h-7 text-xs min-w-[64px]" | ||
| 254 | + value={r.longitude === null || r.longitude === undefined ? "" : String(r.longitude)} | ||
| 255 | + onChange={(e) => { | ||
| 256 | + const v = e.target.value.trim(); | ||
| 257 | + updateRow(idx, { longitude: v === "" ? null : Number(v) }); | ||
| 258 | + }} | ||
| 259 | + /> | ||
| 260 | + </td> | ||
| 261 | + <td className="border p-1 align-middle text-center"> | ||
| 262 | + <div className="flex justify-center"> | ||
| 263 | + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} /> | ||
| 264 | + </div> | ||
| 265 | + </td> | ||
| 266 | + </tr> | ||
| 267 | + ))} | ||
| 268 | + </tbody> | ||
| 269 | + </table> | ||
| 270 | + </div> | ||
| 271 | + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1"> | ||
| 272 | + <p>You can copy and paste from Excel or Google Sheets.</p> | ||
| 273 | + <p>Columns marked * are required for rows that have a valid Location row id.</p> | ||
| 274 | + <p>Use the × on each row to remove a row (keep at least one row).</p> | ||
| 275 | + </div> | ||
| 276 | + </DialogContent> | ||
| 277 | + </Dialog> | ||
| 278 | + ); | ||
| 279 | +} |
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
| @@ -68,8 +68,11 @@ import { getLocations } from "../../services/locationService"; | @@ -68,8 +68,11 @@ import { getLocations } from "../../services/locationService"; | ||
| 68 | import { | 68 | import { |
| 69 | createTeamMember, | 69 | createTeamMember, |
| 70 | deleteTeamMember, | 70 | deleteTeamMember, |
| 71 | + downloadTeamMemberImportTemplate, | ||
| 72 | + exportTeamMembersPdf, | ||
| 71 | getTeamMemberById, | 73 | getTeamMemberById, |
| 72 | getTeamMembers, | 74 | getTeamMembers, |
| 75 | + importTeamMembersBatch, | ||
| 73 | updateTeamMember, | 76 | updateTeamMember, |
| 74 | } from "../../services/teamMemberService"; | 77 | } from "../../services/teamMemberService"; |
| 75 | import type { LocationDto } from "../../types/location"; | 78 | import type { LocationDto } from "../../types/location"; |
| @@ -85,6 +88,8 @@ import { createGroup, deleteGroup, exportGroupsPdf, getGroups, updateGroup } fro | @@ -85,6 +88,8 @@ import { createGroup, deleteGroup, exportGroupsPdf, getGroups, updateGroup } fro | ||
| 85 | import type { GroupListItem } from "../../types/group"; | 88 | import type { GroupListItem } from "../../types/group"; |
| 86 | import type { PartnerListItem } from "../../types/partner"; | 89 | import type { PartnerListItem } from "../../types/partner"; |
| 87 | import { LocationsView } from "../locations/LocationsView"; | 90 | import { LocationsView } from "../locations/LocationsView"; |
| 91 | +import { BatchImportDialog } from "../bulk/batch-import-dialog"; | ||
| 92 | +import { TeamMemberBulkEditPage } from "./team-member-bulk-edit-page"; | ||
| 88 | 93 | ||
| 89 | function downloadBlob(blob: Blob, filename: string) { | 94 | function downloadBlob(blob: Blob, filename: string) { |
| 90 | const url = URL.createObjectURL(blob); | 95 | const url = URL.createObjectURL(blob); |
| @@ -171,6 +176,13 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -171,6 +176,13 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 171 | } | 176 | } |
| 172 | }, [initialSubTab, onInitialSubTabConsumed]); | 177 | }, [initialSubTab, onInitialSubTabConsumed]); |
| 173 | 178 | ||
| 179 | + useEffect(() => { | ||
| 180 | + if (activeTab !== "Team Member") { | ||
| 181 | + setMemberBulkEditPage(false); | ||
| 182 | + setMemberBulkEditSeed([]); | ||
| 183 | + } | ||
| 184 | + }, [activeTab]); | ||
| 185 | + | ||
| 174 | // Data States | 186 | // Data States |
| 175 | const [roles, setRoles] = useState<RoleDto[]>([]); | 187 | const [roles, setRoles] = useState<RoleDto[]>([]); |
| 176 | const [roleTotal, setRoleTotal] = useState(0); | 188 | const [roleTotal, setRoleTotal] = useState(0); |
| @@ -228,6 +240,12 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -228,6 +240,12 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 228 | const [editingMember, setEditingMember] = useState<TeamMemberDto | null>(null); | 240 | const [editingMember, setEditingMember] = useState<TeamMemberDto | null>(null); |
| 229 | const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false); | 241 | const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false); |
| 230 | const [deletingMember, setDeletingMember] = useState<TeamMemberDto | null>(null); | 242 | const [deletingMember, setDeletingMember] = useState<TeamMemberDto | null>(null); |
| 243 | + const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(() => new Set()); | ||
| 244 | + const [memberBulkImportOpen, setMemberBulkImportOpen] = useState(false); | ||
| 245 | + const [memberBulkEditPage, setMemberBulkEditPage] = useState(false); | ||
| 246 | + const [memberBulkEditSeed, setMemberBulkEditSeed] = useState<TeamMemberDto[]>([]); | ||
| 247 | + const [tmplMemberDownloading, setTmplMemberDownloading] = useState(false); | ||
| 248 | + const [memberPdfExporting, setMemberPdfExporting] = useState(false); | ||
| 231 | 249 | ||
| 232 | // Dialog States | 250 | // Dialog States |
| 233 | const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); | 251 | const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); |
| @@ -299,6 +317,10 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -299,6 +317,10 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 299 | }, [memberKeyword]); | 317 | }, [memberKeyword]); |
| 300 | 318 | ||
| 301 | useEffect(() => { | 319 | useEffect(() => { |
| 320 | + setSelectedMemberIds(new Set()); | ||
| 321 | + }, [debouncedMemberKeyword, memberPageIndex]); | ||
| 322 | + | ||
| 323 | + useEffect(() => { | ||
| 302 | if (partnerKeywordTimerRef.current) window.clearTimeout(partnerKeywordTimerRef.current); | 324 | if (partnerKeywordTimerRef.current) window.clearTimeout(partnerKeywordTimerRef.current); |
| 303 | partnerKeywordTimerRef.current = window.setTimeout(() => setDebouncedPartnerKeyword(partnerKeyword.trim()), 300); | 325 | partnerKeywordTimerRef.current = window.setTimeout(() => setDebouncedPartnerKeyword(partnerKeyword.trim()), 300); |
| 304 | return () => { | 326 | return () => { |
| @@ -545,8 +567,6 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -545,8 +567,6 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 545 | ); | 567 | ); |
| 546 | 568 | ||
| 547 | const renderToolbar = () => { | 569 | const renderToolbar = () => { |
| 548 | - const canBulkOps = activeTab === "Team Member"; | ||
| 549 | - | ||
| 550 | return ( | 570 | return ( |
| 551 | <div className="flex flex-col gap-4 pb-4"> | 571 | <div className="flex flex-col gap-4 pb-4"> |
| 552 | {/* Search + Actions - one row, style consistent with Labels / Location Manager */} | 572 | {/* Search + Actions - one row, style consistent with Labels / Location Manager */} |
| @@ -619,19 +639,63 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -619,19 +639,63 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 619 | </> | 639 | </> |
| 620 | )} | 640 | )} |
| 621 | <div className="flex-1" /> | 641 | <div className="flex-1" /> |
| 622 | - {canBulkOps && ( | 642 | + {activeTab === "Team Member" && ( |
| 623 | <> | 643 | <> |
| 624 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> | 644 | + <Button |
| 645 | + type="button" | ||
| 646 | + variant="outline" | ||
| 647 | + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0" | ||
| 648 | + onClick={() => setMemberBulkImportOpen(true)} | ||
| 649 | + > | ||
| 625 | Bulk Import | 650 | Bulk Import |
| 626 | </Button> | 651 | </Button> |
| 627 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> | 652 | + <Button |
| 653 | + type="button" | ||
| 654 | + variant="outline" | ||
| 655 | + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0" | ||
| 656 | + disabled={memberPdfExporting} | ||
| 657 | + onClick={async () => { | ||
| 658 | + setMemberPdfExporting(true); | ||
| 659 | + try { | ||
| 660 | + await exportTeamMembersPdf({ | ||
| 661 | + keyword: debouncedMemberKeyword || undefined, | ||
| 662 | + }); | ||
| 663 | + toast.success("Export started.", { description: "Team member PDF download should begin shortly." }); | ||
| 664 | + } catch (e: unknown) { | ||
| 665 | + const msg = e instanceof Error ? e.message : "Please try again."; | ||
| 666 | + toast.error("Export failed.", { description: msg }); | ||
| 667 | + } finally { | ||
| 668 | + setMemberPdfExporting(false); | ||
| 669 | + } | ||
| 670 | + }} | ||
| 671 | + > | ||
| 672 | + {memberPdfExporting ? "Exporting…" : "Bulk Export (PDF)"} | ||
| 673 | + </Button> | ||
| 674 | + <Button | ||
| 675 | + type="button" | ||
| 676 | + variant="outline" | ||
| 677 | + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0" | ||
| 678 | + onClick={() => { | ||
| 679 | + const seed = members.filter((m) => selectedMemberIds.has(m.id)); | ||
| 680 | + if (seed.length === 0) { | ||
| 681 | + toast.error("No rows selected", { | ||
| 682 | + description: "Use the checkboxes on the left, then open Bulk Edit.", | ||
| 683 | + }); | ||
| 684 | + return; | ||
| 685 | + } | ||
| 686 | + setMemberBulkEditSeed(seed); | ||
| 687 | + setMemberBulkEditPage(true); | ||
| 688 | + }} | ||
| 689 | + > | ||
| 628 | Bulk Edit | 690 | Bulk Edit |
| 629 | </Button> | 691 | </Button> |
| 630 | </> | 692 | </> |
| 631 | )} | 693 | )} |
| 632 | - <Button variant="outline" onClick={handleExportPdf} className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> | ||
| 633 | - Bulk Export (PDF) | ||
| 634 | - </Button> | 694 | + {(activeTab === "Partner" || activeTab === "Group") && ( |
| 695 | + <Button variant="outline" onClick={handleExportPdf} className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> | ||
| 696 | + Bulk Export (PDF) | ||
| 697 | + </Button> | ||
| 698 | + )} | ||
| 635 | <Button | 699 | <Button |
| 636 | className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" | 700 | className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" |
| 637 | onClick={openCreateDialog} | 701 | onClick={openCreateDialog} |
| @@ -1032,6 +1096,16 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -1032,6 +1096,16 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 1032 | <Table> | 1096 | <Table> |
| 1033 | <TableHeader> | 1097 | <TableHeader> |
| 1034 | <TableRow className="bg-gray-100"> | 1098 | <TableRow className="bg-gray-100"> |
| 1099 | + <TableHead className="font-bold text-black border-r w-10 text-center"> | ||
| 1100 | + <Checkbox | ||
| 1101 | + checked={members.length > 0 && members.every((m) => selectedMemberIds.has(m.id))} | ||
| 1102 | + onCheckedChange={(c) => { | ||
| 1103 | + if (c === true) setSelectedMemberIds(new Set(members.map((m) => m.id))); | ||
| 1104 | + else setSelectedMemberIds(new Set()); | ||
| 1105 | + }} | ||
| 1106 | + aria-label="Select all on page" | ||
| 1107 | + /> | ||
| 1108 | + </TableHead> | ||
| 1035 | <TableHead className="font-bold text-black border-r">Name</TableHead> | 1109 | <TableHead className="font-bold text-black border-r">Name</TableHead> |
| 1036 | <TableHead className="font-bold text-black border-r">Email</TableHead> | 1110 | <TableHead className="font-bold text-black border-r">Email</TableHead> |
| 1037 | <TableHead className="font-bold text-black border-r">Phone</TableHead> | 1111 | <TableHead className="font-bold text-black border-r">Phone</TableHead> |
| @@ -1044,19 +1118,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -1044,19 +1118,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 1044 | <TableBody> | 1118 | <TableBody> |
| 1045 | {membersLoading ? ( | 1119 | {membersLoading ? ( |
| 1046 | <TableRow> | 1120 | <TableRow> |
| 1047 | - <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | 1121 | + <TableCell colSpan={8} className="text-center text-sm text-gray-500 py-10"> |
| 1048 | Loading... | 1122 | Loading... |
| 1049 | </TableCell> | 1123 | </TableCell> |
| 1050 | </TableRow> | 1124 | </TableRow> |
| 1051 | ) : members.length === 0 ? ( | 1125 | ) : members.length === 0 ? ( |
| 1052 | <TableRow> | 1126 | <TableRow> |
| 1053 | - <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | 1127 | + <TableCell colSpan={8} className="text-center text-sm text-gray-500 py-10"> |
| 1054 | No results. | 1128 | No results. |
| 1055 | </TableCell> | 1129 | </TableCell> |
| 1056 | </TableRow> | 1130 | </TableRow> |
| 1057 | ) : ( | 1131 | ) : ( |
| 1058 | members.map((m) => ( | 1132 | members.map((m) => ( |
| 1059 | <TableRow key={m.id}> | 1133 | <TableRow key={m.id}> |
| 1134 | + <TableCell className="border-r w-10 text-center"> | ||
| 1135 | + <Checkbox | ||
| 1136 | + checked={selectedMemberIds.has(m.id)} | ||
| 1137 | + onCheckedChange={(c) => { | ||
| 1138 | + setSelectedMemberIds((prev) => { | ||
| 1139 | + const n = new Set(prev); | ||
| 1140 | + if (c === true) n.add(m.id); | ||
| 1141 | + else n.delete(m.id); | ||
| 1142 | + return n; | ||
| 1143 | + }); | ||
| 1144 | + }} | ||
| 1145 | + aria-label="Select row" | ||
| 1146 | + /> | ||
| 1147 | + </TableCell> | ||
| 1060 | <TableCell className="font-medium border-r">{m.fullName ?? m.userName ?? "N/A"}</TableCell> | 1148 | <TableCell className="font-medium border-r">{m.fullName ?? m.userName ?? "N/A"}</TableCell> |
| 1061 | <TableCell className="border-r text-gray-600">{m.email ?? "N/A"}</TableCell> | 1149 | <TableCell className="border-r text-gray-600">{m.email ?? "N/A"}</TableCell> |
| 1062 | <TableCell className="border-r text-gray-600">{m.phone ?? "N/A"}</TableCell> | 1150 | <TableCell className="border-r text-gray-600">{m.phone ?? "N/A"}</TableCell> |
| @@ -1186,12 +1274,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -1186,12 +1274,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 1186 | } | 1274 | } |
| 1187 | }; | 1275 | }; |
| 1188 | 1276 | ||
| 1277 | + const showTeamMemberBulkPage = activeTab === "Team Member" && memberBulkEditPage; | ||
| 1278 | + | ||
| 1189 | return ( | 1279 | return ( |
| 1190 | <div className="h-full flex flex-col"> | 1280 | <div className="h-full flex flex-col"> |
| 1191 | - {activeTab !== "Location Manager" ? renderToolbar() : null} | 1281 | + {activeTab !== "Location Manager" && !showTeamMemberBulkPage ? renderToolbar() : null} |
| 1192 | 1282 | ||
| 1193 | {activeTab === "Location Manager" ? ( | 1283 | {activeTab === "Location Manager" ? ( |
| 1194 | <div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderContent()}</div> | 1284 | <div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderContent()}</div> |
| 1285 | + ) : showTeamMemberBulkPage ? ( | ||
| 1286 | + <div className="flex-1 min-h-0 flex flex-col overflow-hidden pt-6"> | ||
| 1287 | + <div className="shrink-0 pb-4">{renderAccountTabsRow()}</div> | ||
| 1288 | + <div className="flex-1 min-h-0 flex flex-col overflow-hidden"> | ||
| 1289 | + <div className="bg-white border border-gray-200 shadow-sm rounded-md flex-1 flex flex-col min-h-0 overflow-hidden"> | ||
| 1290 | + <TeamMemberBulkEditPage | ||
| 1291 | + seed={memberBulkEditSeed} | ||
| 1292 | + onBack={() => { | ||
| 1293 | + setMemberBulkEditPage(false); | ||
| 1294 | + setMemberBulkEditSeed([]); | ||
| 1295 | + }} | ||
| 1296 | + onSaved={() => { | ||
| 1297 | + setSelectedMemberIds(new Set()); | ||
| 1298 | + setMemberRefreshSeq((x) => x + 1); | ||
| 1299 | + }} | ||
| 1300 | + /> | ||
| 1301 | + </div> | ||
| 1302 | + </div> | ||
| 1303 | + </div> | ||
| 1195 | ) : ( | 1304 | ) : ( |
| 1196 | <div className="flex-1 overflow-auto pt-6"> | 1305 | <div className="flex-1 overflow-auto pt-6"> |
| 1197 | <div className="bg-white border border-gray-200 shadow-sm rounded-md">{renderContent()}</div> | 1306 | <div className="bg-white border border-gray-200 shadow-sm rounded-md">{renderContent()}</div> |
| @@ -1286,6 +1395,30 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | @@ -1286,6 +1395,30 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie | ||
| 1286 | setMemberRefreshSeq((x) => x + 1); | 1395 | setMemberRefreshSeq((x) => x + 1); |
| 1287 | }} | 1396 | }} |
| 1288 | /> | 1397 | /> |
| 1398 | + <BatchImportDialog | ||
| 1399 | + open={memberBulkImportOpen} | ||
| 1400 | + onOpenChange={setMemberBulkImportOpen} | ||
| 1401 | + title="Bulk import team members" | ||
| 1402 | + description="Upload an .xlsx file. Use the official template for column headers." | ||
| 1403 | + downloadingTemplate={tmplMemberDownloading} | ||
| 1404 | + onDownloadTemplate={async () => { | ||
| 1405 | + setTmplMemberDownloading(true); | ||
| 1406 | + try { | ||
| 1407 | + await downloadTeamMemberImportTemplate(); | ||
| 1408 | + toast.success("Template downloaded."); | ||
| 1409 | + } catch (e: unknown) { | ||
| 1410 | + const msg = e instanceof Error ? e.message : "Download failed."; | ||
| 1411 | + toast.error("Template download failed", { description: msg }); | ||
| 1412 | + } finally { | ||
| 1413 | + setTmplMemberDownloading(false); | ||
| 1414 | + } | ||
| 1415 | + }} | ||
| 1416 | + onImportFile={async (file) => { | ||
| 1417 | + const r = await importTeamMembersBatch(file); | ||
| 1418 | + setMemberRefreshSeq((x) => x + 1); | ||
| 1419 | + return { successCount: r.successCount, failCount: r.failCount }; | ||
| 1420 | + }} | ||
| 1421 | + /> | ||
| 1289 | <DeleteMemberDialog | 1422 | <DeleteMemberDialog |
| 1290 | open={isDeleteMemberDialogOpen} | 1423 | open={isDeleteMemberDialogOpen} |
| 1291 | member={deletingMember} | 1424 | member={deletingMember} |
美国版/Food Labeling Management Platform/src/components/people/team-member-bulk-edit-page.tsx
0 → 100644
| 1 | +import React, { useEffect, useState } from "react"; | ||
| 2 | +import { Button } from "../ui/button"; | ||
| 3 | +import { Input } from "../ui/input"; | ||
| 4 | +import { Switch } from "../ui/switch"; | ||
| 5 | +import { | ||
| 6 | + Select, | ||
| 7 | + SelectContent, | ||
| 8 | + SelectItem, | ||
| 9 | + SelectTrigger, | ||
| 10 | + SelectValue, | ||
| 11 | +} from "../ui/select"; | ||
| 12 | +import { toast } from "sonner"; | ||
| 13 | +import { ApiError } from "../../lib/apiClient"; | ||
| 14 | +import { getRoles } from "../../services/roleService"; | ||
| 15 | +import { updateTeamMembersBulk, type TeamMemberBulkUpdateItemVo } from "../../services/teamMemberService"; | ||
| 16 | +import type { RoleDto } from "../../types/role"; | ||
| 17 | +import type { TeamMemberDto } from "../../types/teamMember"; | ||
| 18 | + | ||
| 19 | +const ZERO = "00000000-0000-0000-0000-000000000000"; | ||
| 20 | + | ||
| 21 | +/** 列表/接口可能把 phone 等字段打成数字;统一成字符串再 trim,避免白屏 */ | ||
| 22 | +function trimStr(v: unknown): string { | ||
| 23 | + if (v == null) return ""; | ||
| 24 | + return String(v).trim(); | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +function isValidBulkId(id: string): boolean { | ||
| 28 | + const s = (id ?? "").trim(); | ||
| 29 | + if (!s) return false; | ||
| 30 | + return s.toLowerCase() !== ZERO; | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +function toPhoneNumber(v: string): number | null { | ||
| 34 | + const s = v.trim(); | ||
| 35 | + if (!s) return null; | ||
| 36 | + const num = Number(s.replace(/\D/g, "")) || 0; | ||
| 37 | + return num; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +export type TeamMemberBulkEditPageProps = { | ||
| 41 | + seed: TeamMemberDto[]; | ||
| 42 | + onBack: () => void; | ||
| 43 | + onSaved: () => void; | ||
| 44 | +}; | ||
| 45 | + | ||
| 46 | +type RowState = { | ||
| 47 | + id: string; | ||
| 48 | + fullName: string; | ||
| 49 | + userName: string; | ||
| 50 | + password: string; | ||
| 51 | + email: string; | ||
| 52 | + phone: string; | ||
| 53 | + roleId: string; | ||
| 54 | + locationIdsCsv: string; | ||
| 55 | + state: boolean; | ||
| 56 | +}; | ||
| 57 | + | ||
| 58 | +function memberToRow(m: TeamMemberDto): RowState { | ||
| 59 | + const lids = Array.isArray(m.locationIds) ? m.locationIds : []; | ||
| 60 | + return { | ||
| 61 | + id: trimStr(m.id), | ||
| 62 | + fullName: trimStr(m.fullName), | ||
| 63 | + userName: trimStr(m.userName), | ||
| 64 | + password: "", | ||
| 65 | + email: trimStr(m.email), | ||
| 66 | + phone: trimStr(m.phone), | ||
| 67 | + roleId: trimStr(m.roleId), | ||
| 68 | + locationIdsCsv: lids.map((x) => trimStr(x)).filter(Boolean).join(","), | ||
| 69 | + state: m.state !== false, | ||
| 70 | + }; | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +function parseIdsCsv(s: string): string[] { | ||
| 74 | + return s | ||
| 75 | + .split(/[,;|\s]+/) | ||
| 76 | + .map((x) => x.trim()) | ||
| 77 | + .filter(Boolean); | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +export function TeamMemberBulkEditPage({ seed, onBack, onSaved }: TeamMemberBulkEditPageProps) { | ||
| 81 | + const [rows, setRows] = useState<RowState[]>([]); | ||
| 82 | + const [roles, setRoles] = useState<RoleDto[]>([]); | ||
| 83 | + const [saving, setSaving] = useState(false); | ||
| 84 | + | ||
| 85 | + useEffect(() => { | ||
| 86 | + let c = false; | ||
| 87 | + (async () => { | ||
| 88 | + try { | ||
| 89 | + const out: RoleDto[] = []; | ||
| 90 | + let page = 1; | ||
| 91 | + const size = 100; | ||
| 92 | + for (;;) { | ||
| 93 | + const res = await getRoles({ skipCount: page, maxResultCount: size }); | ||
| 94 | + out.push(...(res.items ?? [])); | ||
| 95 | + if (!res.items || res.items.length < size) break; | ||
| 96 | + page += 1; | ||
| 97 | + if (page > 50) break; | ||
| 98 | + } | ||
| 99 | + if (!c) setRoles(out); | ||
| 100 | + } catch { | ||
| 101 | + if (!c) setRoles([]); | ||
| 102 | + } | ||
| 103 | + })(); | ||
| 104 | + return () => { | ||
| 105 | + c = true; | ||
| 106 | + }; | ||
| 107 | + }, []); | ||
| 108 | + | ||
| 109 | + useEffect(() => { | ||
| 110 | + setRows(seed.map(memberToRow)); | ||
| 111 | + }, [seed]); | ||
| 112 | + | ||
| 113 | + const updateRow = (idx: number, patch: Partial<RowState>) => { | ||
| 114 | + setRows((prev) => { | ||
| 115 | + const next = [...prev]; | ||
| 116 | + next[idx] = { ...next[idx], ...patch }; | ||
| 117 | + return next; | ||
| 118 | + }); | ||
| 119 | + }; | ||
| 120 | + | ||
| 121 | + const handleSave = async () => { | ||
| 122 | + const items: TeamMemberBulkUpdateItemVo[] = rows | ||
| 123 | + .filter((r) => isValidBulkId(r.id)) | ||
| 124 | + .map((r) => { | ||
| 125 | + const item: TeamMemberBulkUpdateItemVo = { | ||
| 126 | + id: r.id.trim(), | ||
| 127 | + fullName: r.fullName.trim(), | ||
| 128 | + userName: r.userName.trim(), | ||
| 129 | + email: r.email.trim() || null, | ||
| 130 | + phone: toPhoneNumber(r.phone), | ||
| 131 | + roleId: r.roleId.trim(), | ||
| 132 | + locationIds: parseIdsCsv(r.locationIdsCsv), | ||
| 133 | + state: r.state !== false, | ||
| 134 | + }; | ||
| 135 | + const pw = r.password.trim(); | ||
| 136 | + if (pw) item.password = pw; | ||
| 137 | + return item; | ||
| 138 | + }); | ||
| 139 | + | ||
| 140 | + if (items.length === 0) { | ||
| 141 | + toast.error("No valid rows", { description: "Select team members in the list first." }); | ||
| 142 | + return; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + setSaving(true); | ||
| 146 | + try { | ||
| 147 | + const res = await updateTeamMembersBulk({ items }); | ||
| 148 | + toast.success("Bulk update finished", { | ||
| 149 | + description: `Success: ${res.successCount}, failed: ${res.failCount}`, | ||
| 150 | + }); | ||
| 151 | + onSaved(); | ||
| 152 | + onBack(); | ||
| 153 | + } catch (e) { | ||
| 154 | + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed."; | ||
| 155 | + toast.error("Bulk save failed", { description: msg }); | ||
| 156 | + } finally { | ||
| 157 | + setSaving(false); | ||
| 158 | + } | ||
| 159 | + }; | ||
| 160 | + | ||
| 161 | + return ( | ||
| 162 | + <div className="flex flex-col h-full min-h-0 bg-white"> | ||
| 163 | + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0"> | ||
| 164 | + <Button type="button" variant="outline" onClick={onBack}> | ||
| 165 | + Back | ||
| 166 | + </Button> | ||
| 167 | + <h1 className="text-base font-semibold text-gray-900 flex-1 text-center truncate px-2"> | ||
| 168 | + Team member bulk edit | ||
| 169 | + </h1> | ||
| 170 | + <Button | ||
| 171 | + type="button" | ||
| 172 | + className="bg-green-600 hover:bg-green-700 text-white shrink-0" | ||
| 173 | + disabled={saving} | ||
| 174 | + onClick={() => void handleSave()} | ||
| 175 | + > | ||
| 176 | + {saving ? "Saving…" : "Save All"} | ||
| 177 | + </Button> | ||
| 178 | + </div> | ||
| 179 | + <div className="overflow-auto flex-1 min-h-0 px-2 py-3"> | ||
| 180 | + <table className="w-full text-xs border-collapse border border-gray-200"> | ||
| 181 | + <thead className="bg-gray-100 sticky top-0 z-10"> | ||
| 182 | + <tr> | ||
| 183 | + <th className="border p-1 w-9 text-center text-gray-600 font-semibold">#</th> | ||
| 184 | + <th className="border p-1 whitespace-nowrap">Full name *</th> | ||
| 185 | + <th className="border p-1 whitespace-nowrap">User name *</th> | ||
| 186 | + <th className="border p-1 whitespace-nowrap">Password</th> | ||
| 187 | + <th className="border p-1 whitespace-nowrap">Email</th> | ||
| 188 | + <th className="border p-1 whitespace-nowrap">Phone</th> | ||
| 189 | + <th className="border p-1 whitespace-nowrap">Role *</th> | ||
| 190 | + <th className="border p-1 whitespace-nowrap">Location IDs</th> | ||
| 191 | + <th className="border p-1 whitespace-nowrap">Active</th> | ||
| 192 | + </tr> | ||
| 193 | + </thead> | ||
| 194 | + <tbody> | ||
| 195 | + {rows.map((r, idx) => ( | ||
| 196 | + <tr key={`${r.id || "e"}-${idx}`}> | ||
| 197 | + <td className="border p-1 text-center align-middle text-gray-700 tabular-nums text-xs font-medium"> | ||
| 198 | + {idx + 1} | ||
| 199 | + </td> | ||
| 200 | + <td className="border p-1 align-top"> | ||
| 201 | + <Input | ||
| 202 | + className="h-7 text-xs min-w-[100px]" | ||
| 203 | + value={r.fullName} | ||
| 204 | + onChange={(e) => updateRow(idx, { fullName: e.target.value })} | ||
| 205 | + /> | ||
| 206 | + </td> | ||
| 207 | + <td className="border p-1 align-top"> | ||
| 208 | + <Input | ||
| 209 | + className="h-7 text-xs min-w-[100px]" | ||
| 210 | + value={r.userName} | ||
| 211 | + onChange={(e) => updateRow(idx, { userName: e.target.value })} | ||
| 212 | + /> | ||
| 213 | + </td> | ||
| 214 | + <td className="border p-1 align-top"> | ||
| 215 | + <Input | ||
| 216 | + className="h-7 text-xs min-w-[80px]" | ||
| 217 | + type="password" | ||
| 218 | + placeholder="(unchanged)" | ||
| 219 | + value={r.password} | ||
| 220 | + onChange={(e) => updateRow(idx, { password: e.target.value })} | ||
| 221 | + /> | ||
| 222 | + </td> | ||
| 223 | + <td className="border p-1 align-top"> | ||
| 224 | + <Input | ||
| 225 | + className="h-7 text-xs min-w-[120px]" | ||
| 226 | + value={r.email} | ||
| 227 | + onChange={(e) => updateRow(idx, { email: e.target.value })} | ||
| 228 | + /> | ||
| 229 | + </td> | ||
| 230 | + <td className="border p-1 align-top"> | ||
| 231 | + <Input | ||
| 232 | + className="h-7 text-xs min-w-[88px]" | ||
| 233 | + value={r.phone} | ||
| 234 | + onChange={(e) => updateRow(idx, { phone: e.target.value })} | ||
| 235 | + /> | ||
| 236 | + </td> | ||
| 237 | + <td className="border p-1 align-top min-w-[140px]"> | ||
| 238 | + <Select value={r.roleId || "__none__"} onValueChange={(v) => updateRow(idx, { roleId: v === "__none__" ? "" : v })}> | ||
| 239 | + <SelectTrigger className="h-7 text-xs"> | ||
| 240 | + <SelectValue placeholder="Role" /> | ||
| 241 | + </SelectTrigger> | ||
| 242 | + <SelectContent> | ||
| 243 | + <SelectItem value="__none__">(select)</SelectItem> | ||
| 244 | + {roles.map((role) => ( | ||
| 245 | + <SelectItem key={role.id} value={role.id}> | ||
| 246 | + {role.roleName ?? role.id} | ||
| 247 | + </SelectItem> | ||
| 248 | + ))} | ||
| 249 | + </SelectContent> | ||
| 250 | + </Select> | ||
| 251 | + </td> | ||
| 252 | + <td className="border p-1 align-top"> | ||
| 253 | + <Input | ||
| 254 | + className="h-7 text-xs min-w-[140px]" | ||
| 255 | + value={r.locationIdsCsv} | ||
| 256 | + onChange={(e) => updateRow(idx, { locationIdsCsv: e.target.value })} | ||
| 257 | + placeholder="guid1,guid2" | ||
| 258 | + /> | ||
| 259 | + </td> | ||
| 260 | + <td className="border p-1 text-center align-middle"> | ||
| 261 | + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} /> | ||
| 262 | + </td> | ||
| 263 | + </tr> | ||
| 264 | + ))} | ||
| 265 | + </tbody> | ||
| 266 | + </table> | ||
| 267 | + </div> | ||
| 268 | + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1"> | ||
| 269 | + <p>Leave password empty to keep the current password.</p> | ||
| 270 | + <p>Location IDs: comma-separated location primary keys.</p> | ||
| 271 | + </div> | ||
| 272 | + </div> | ||
| 273 | + ); | ||
| 274 | +} |
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
| @@ -10,6 +10,7 @@ import { | @@ -10,6 +10,7 @@ import { | ||
| 10 | Trash2, | 10 | Trash2, |
| 11 | } from "lucide-react"; | 11 | } from "lucide-react"; |
| 12 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| 13 | +import { Checkbox } from "../ui/checkbox"; | ||
| 13 | import { Input } from "../ui/input"; | 14 | import { Input } from "../ui/input"; |
| 14 | import { | 15 | import { |
| 15 | Table, | 16 | Table, |
| @@ -66,8 +67,11 @@ import { | @@ -66,8 +67,11 @@ import { | ||
| 66 | import { | 67 | import { |
| 67 | createProduct, | 68 | createProduct, |
| 68 | deleteProduct, | 69 | deleteProduct, |
| 70 | + downloadProductImportTemplate, | ||
| 71 | + exportProductsExcel, | ||
| 69 | getProduct, | 72 | getProduct, |
| 70 | getProducts, | 73 | getProducts, |
| 74 | + importProductsBatch, | ||
| 71 | updateProduct, | 75 | updateProduct, |
| 72 | } from "../../services/productService"; | 76 | } from "../../services/productService"; |
| 73 | import { getProductIdsByLocation, getProductLocations } from "../../services/productLocationService"; | 77 | import { getProductIdsByLocation, getProductLocations } from "../../services/productLocationService"; |
| @@ -76,6 +80,8 @@ import type { ProductDto, ProductCreateInput, ProductUpdateInput } from "../../t | @@ -76,6 +80,8 @@ import type { ProductDto, ProductCreateInput, ProductUpdateInput } from "../../t | ||
| 76 | import type { ProductCategoryDto, ProductCategoryCreateInput } from "../../types/productCategory"; | 80 | import type { ProductCategoryDto, ProductCategoryCreateInput } from "../../types/productCategory"; |
| 77 | import { SearchableSelect } from "../ui/searchable-select"; | 81 | import { SearchableSelect } from "../ui/searchable-select"; |
| 78 | import { SearchableMultiSelect } from "../ui/searchable-multi-select"; | 82 | import { SearchableMultiSelect } from "../ui/searchable-multi-select"; |
| 83 | +import { BatchImportDialog } from "../bulk/batch-import-dialog"; | ||
| 84 | +import { ProductBulkEditDialog } from "./product-bulk-edit-dialog"; | ||
| 79 | import { | 85 | import { |
| 80 | Pagination, | 86 | Pagination, |
| 81 | PaginationContent, | 87 | PaginationContent, |
| @@ -162,6 +168,12 @@ export function ProductsView() { | @@ -162,6 +168,12 @@ export function ProductsView() { | ||
| 162 | const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null); | 168 | const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null); |
| 163 | const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null); | 169 | const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null); |
| 164 | const [actionsOpenId, setActionsOpenId] = useState<string | null>(null); | 170 | const [actionsOpenId, setActionsOpenId] = useState<string | null>(null); |
| 171 | + const [selectedProductIds, setSelectedProductIds] = useState<Set<string>>(() => new Set()); | ||
| 172 | + const [bulkImportOpen, setBulkImportOpen] = useState(false); | ||
| 173 | + const [bulkEditOpen, setBulkEditOpen] = useState(false); | ||
| 174 | + const [bulkEditSeed, setBulkEditSeed] = useState<ProductDto[]>([]); | ||
| 175 | + const [tmplDownloading, setTmplDownloading] = useState(false); | ||
| 176 | + const [excelExporting, setExcelExporting] = useState(false); | ||
| 165 | 177 | ||
| 166 | useEffect(() => { | 178 | useEffect(() => { |
| 167 | if (keywordTimer.current) window.clearTimeout(keywordTimer.current); | 179 | if (keywordTimer.current) window.clearTimeout(keywordTimer.current); |
| @@ -341,6 +353,10 @@ export function ProductsView() { | @@ -341,6 +353,10 @@ export function ProductsView() { | ||
| 341 | 353 | ||
| 342 | const refresh = () => setRefreshSeq((x) => x + 1); | 354 | const refresh = () => setRefreshSeq((x) => x + 1); |
| 343 | 355 | ||
| 356 | + useEffect(() => { | ||
| 357 | + setSelectedProductIds(new Set()); | ||
| 358 | + }, [debouncedKeyword, locationFilter, categoryFilter, stateFilter, pageIndex]); | ||
| 359 | + | ||
| 344 | const refreshCategories = () => { | 360 | const refreshCategories = () => { |
| 345 | setCatRefreshSeq((x) => x + 1); | 361 | setCatRefreshSeq((x) => x + 1); |
| 346 | reloadCategoryCatalog(); | 362 | reloadCategoryCatalog(); |
| @@ -461,38 +477,6 @@ export function ProductsView() { | @@ -461,38 +477,6 @@ export function ProductsView() { | ||
| 461 | </SelectContent> | 477 | </SelectContent> |
| 462 | </Select> | 478 | </Select> |
| 463 | </div> | 479 | </div> |
| 464 | - <div className="flex flex-nowrap items-center justify-end gap-3 min-w-0 overflow-x-auto pb-0.5 [scrollbar-width:thin]"> | ||
| 465 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled> | ||
| 466 | - <Upload className="w-4 h-4" /> Bulk Import | ||
| 467 | - </Button> | ||
| 468 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled> | ||
| 469 | - <Download className="w-4 h-4" /> Bulk Export | ||
| 470 | - </Button> | ||
| 471 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled> | ||
| 472 | - <Edit className="w-4 h-4" /> Bulk Edit | ||
| 473 | - </Button> | ||
| 474 | - {activeTab === "products" ? ( | ||
| 475 | - <Button | ||
| 476 | - className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" | ||
| 477 | - onClick={() => { | ||
| 478 | - setEditingProduct(null); | ||
| 479 | - setIsProductDialogOpen(true); | ||
| 480 | - }} | ||
| 481 | - > | ||
| 482 | - New Product <Plus className="w-4 h-4" /> | ||
| 483 | - </Button> | ||
| 484 | - ) : ( | ||
| 485 | - <Button | ||
| 486 | - className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" | ||
| 487 | - onClick={() => { | ||
| 488 | - setEditingProductCategory(null); | ||
| 489 | - setIsProductCategoryDialogOpen(true); | ||
| 490 | - }} | ||
| 491 | - > | ||
| 492 | - New Category <Plus className="w-4 h-4" /> | ||
| 493 | - </Button> | ||
| 494 | - )} | ||
| 495 | - </div> | ||
| 496 | </div> | 480 | </div> |
| 497 | 481 | ||
| 498 | <div className="w-full border-b border-gray-200 mt-4"> | 482 | <div className="w-full border-b border-gray-200 mt-4"> |
| @@ -533,12 +517,101 @@ export function ProductsView() { | @@ -533,12 +517,101 @@ export function ProductsView() { | ||
| 533 | </div> | 517 | </div> |
| 534 | </div> | 518 | </div> |
| 535 | 519 | ||
| 536 | - <div className="flex-1 overflow-auto pt-6"> | 520 | + <div className="flex-1 overflow-auto pt-6 flex flex-col min-h-0"> |
| 521 | + <div className="flex flex-nowrap items-center justify-end gap-3 min-w-0 overflow-x-auto pb-0.5 mb-3 shrink-0 [scrollbar-width:thin]"> | ||
| 522 | + {activeTab === "products" && ( | ||
| 523 | + <> | ||
| 524 | + <Button | ||
| 525 | + type="button" | ||
| 526 | + variant="outline" | ||
| 527 | + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" | ||
| 528 | + onClick={() => setBulkImportOpen(true)} | ||
| 529 | + > | ||
| 530 | + <Upload className="w-4 h-4" /> Bulk Import | ||
| 531 | + </Button> | ||
| 532 | + <Button | ||
| 533 | + type="button" | ||
| 534 | + variant="outline" | ||
| 535 | + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" | ||
| 536 | + disabled={excelExporting} | ||
| 537 | + onClick={async () => { | ||
| 538 | + setExcelExporting(true); | ||
| 539 | + try { | ||
| 540 | + await exportProductsExcel({ | ||
| 541 | + keyword: debouncedKeyword || undefined, | ||
| 542 | + state: stateFilter === "all" ? undefined : stateFilter === "true", | ||
| 543 | + }); | ||
| 544 | + toast.success("Export started", { description: "Your browser should download the Excel file." }); | ||
| 545 | + } catch (e: unknown) { | ||
| 546 | + const msg = e instanceof Error ? e.message : "Please try again."; | ||
| 547 | + toast.error("Export failed", { description: msg }); | ||
| 548 | + } finally { | ||
| 549 | + setExcelExporting(false); | ||
| 550 | + } | ||
| 551 | + }} | ||
| 552 | + > | ||
| 553 | + <Download className="w-4 h-4" /> {excelExporting ? "Exporting…" : "Bulk Export"} | ||
| 554 | + </Button> | ||
| 555 | + <Button | ||
| 556 | + type="button" | ||
| 557 | + variant="outline" | ||
| 558 | + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" | ||
| 559 | + onClick={() => { | ||
| 560 | + const seed = products | ||
| 561 | + .filter((p) => selectedProductIds.has(p.id)) | ||
| 562 | + .map((p) => ({ | ||
| 563 | + ...p, | ||
| 564 | + locationIds: locationMap.get(p.id) ?? p.locationIds ?? [], | ||
| 565 | + })); | ||
| 566 | + if (seed.length === 0) { | ||
| 567 | + toast.error("No rows selected", { description: "Use the checkboxes on the left, then open Bulk Edit." }); | ||
| 568 | + return; | ||
| 569 | + } | ||
| 570 | + setBulkEditSeed(seed); | ||
| 571 | + setBulkEditOpen(true); | ||
| 572 | + }} | ||
| 573 | + > | ||
| 574 | + <Edit className="w-4 h-4" /> Bulk Edit | ||
| 575 | + </Button> | ||
| 576 | + </> | ||
| 577 | + )} | ||
| 578 | + {activeTab === "products" ? ( | ||
| 579 | + <Button | ||
| 580 | + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" | ||
| 581 | + onClick={() => { | ||
| 582 | + setEditingProduct(null); | ||
| 583 | + setIsProductDialogOpen(true); | ||
| 584 | + }} | ||
| 585 | + > | ||
| 586 | + New Product <Plus className="w-4 h-4" /> | ||
| 587 | + </Button> | ||
| 588 | + ) : ( | ||
| 589 | + <Button | ||
| 590 | + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" | ||
| 591 | + onClick={() => { | ||
| 592 | + setEditingProductCategory(null); | ||
| 593 | + setIsProductCategoryDialogOpen(true); | ||
| 594 | + }} | ||
| 595 | + > | ||
| 596 | + New Category <Plus className="w-4 h-4" /> | ||
| 597 | + </Button> | ||
| 598 | + )} | ||
| 599 | + </div> | ||
| 537 | {activeTab === "products" ? ( | 600 | {activeTab === "products" ? ( |
| 538 | <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden"> | 601 | <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden"> |
| 539 | <Table> | 602 | <Table> |
| 540 | <TableHeader> | 603 | <TableHeader> |
| 541 | <TableRow className="bg-gray-100 hover:bg-gray-100"> | 604 | <TableRow className="bg-gray-100 hover:bg-gray-100"> |
| 605 | + <TableHead className="text-gray-900 font-bold border-r w-10 text-center whitespace-nowrap"> | ||
| 606 | + <Checkbox | ||
| 607 | + checked={products.length > 0 && products.every((p) => selectedProductIds.has(p.id))} | ||
| 608 | + onCheckedChange={(c) => { | ||
| 609 | + if (c === true) setSelectedProductIds(new Set(products.map((p) => p.id))); | ||
| 610 | + else setSelectedProductIds(new Set()); | ||
| 611 | + }} | ||
| 612 | + aria-label="Select all on page" | ||
| 613 | + /> | ||
| 614 | + </TableHead> | ||
| 542 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Location</TableHead> | 615 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Location</TableHead> |
| 543 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product Category</TableHead> | 616 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product Category</TableHead> |
| 544 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product</TableHead> | 617 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product</TableHead> |
| @@ -551,13 +624,13 @@ export function ProductsView() { | @@ -551,13 +624,13 @@ export function ProductsView() { | ||
| 551 | <TableBody> | 624 | <TableBody> |
| 552 | {loading ? ( | 625 | {loading ? ( |
| 553 | <TableRow> | 626 | <TableRow> |
| 554 | - <TableCell colSpan={7} className="text-center text-gray-500 py-10"> | 627 | + <TableCell colSpan={8} className="text-center text-gray-500 py-10"> |
| 555 | Loading... | 628 | Loading... |
| 556 | </TableCell> | 629 | </TableCell> |
| 557 | </TableRow> | 630 | </TableRow> |
| 558 | ) : products.length === 0 ? ( | 631 | ) : products.length === 0 ? ( |
| 559 | <TableRow> | 632 | <TableRow> |
| 560 | - <TableCell colSpan={7} className="text-center text-gray-500 py-10"> | 633 | + <TableCell colSpan={8} className="text-center text-gray-500 py-10"> |
| 561 | No products found. | 634 | No products found. |
| 562 | </TableCell> | 635 | </TableCell> |
| 563 | </TableRow> | 636 | </TableRow> |
| @@ -571,6 +644,20 @@ export function ProductsView() { | @@ -571,6 +644,20 @@ export function ProductsView() { | ||
| 571 | const active = p.state !== false; | 644 | const active = p.state !== false; |
| 572 | return ( | 645 | return ( |
| 573 | <TableRow key={p.id}> | 646 | <TableRow key={p.id}> |
| 647 | + <TableCell className="border-r w-10 text-center"> | ||
| 648 | + <Checkbox | ||
| 649 | + checked={selectedProductIds.has(p.id)} | ||
| 650 | + onCheckedChange={(c) => { | ||
| 651 | + setSelectedProductIds((prev) => { | ||
| 652 | + const n = new Set(prev); | ||
| 653 | + if (c === true) n.add(p.id); | ||
| 654 | + else n.delete(p.id); | ||
| 655 | + return n; | ||
| 656 | + }); | ||
| 657 | + }} | ||
| 658 | + aria-label="Select row" | ||
| 659 | + /> | ||
| 660 | + </TableCell> | ||
| 574 | <TableCell className="border-r text-sm max-w-[200px] truncate" title={locText}> | 661 | <TableCell className="border-r text-sm max-w-[200px] truncate" title={locText}> |
| 575 | {locText} | 662 | {locText} |
| 576 | </TableCell> | 663 | </TableCell> |
| @@ -657,10 +744,11 @@ export function ProductsView() { | @@ -657,10 +744,11 @@ export function ProductsView() { | ||
| 657 | )} | 744 | )} |
| 658 | </TableBody> | 745 | </TableBody> |
| 659 | </Table> | 746 | </Table> |
| 660 | - <div className="flex items-center justify-between px-3 py-2 text-sm text-gray-600 border-t border-gray-100"> | 747 | + <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm text-gray-600 border-t border-gray-100"> |
| 661 | <span> | 748 | <span> |
| 662 | - Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- | ||
| 663 | - {Math.min(pageIndex * pageSize, total)} of {total} | 749 | + {total === 0 |
| 750 | + ? "Showing 0 of 0" | ||
| 751 | + : `Showing ${(pageIndex - 1) * pageSize + 1}–${Math.min(pageIndex * pageSize, total)} of ${total}`} | ||
| 664 | </span> | 752 | </span> |
| 665 | <div className="flex items-center gap-2"> | 753 | <div className="flex items-center gap-2"> |
| 666 | <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> | 754 | <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> |
| @@ -675,27 +763,34 @@ export function ProductsView() { | @@ -675,27 +763,34 @@ export function ProductsView() { | ||
| 675 | ))} | 763 | ))} |
| 676 | </SelectContent> | 764 | </SelectContent> |
| 677 | </Select> | 765 | </Select> |
| 678 | - <Button | ||
| 679 | - type="button" | ||
| 680 | - variant="outline" | ||
| 681 | - size="sm" | ||
| 682 | - disabled={pageIndex <= 1} | ||
| 683 | - onClick={() => setPageIndex((x) => Math.max(1, x - 1))} | ||
| 684 | - > | ||
| 685 | - Prev | ||
| 686 | - </Button> | ||
| 687 | - <span className="text-xs tabular-nums"> | ||
| 688 | - Page {pageIndex} / {totalPages} | ||
| 689 | - </span> | ||
| 690 | - <Button | ||
| 691 | - type="button" | ||
| 692 | - variant="outline" | ||
| 693 | - size="sm" | ||
| 694 | - disabled={pageIndex >= totalPages} | ||
| 695 | - onClick={() => setPageIndex((x) => Math.min(totalPages, x + 1))} | ||
| 696 | - > | ||
| 697 | - Next | ||
| 698 | - </Button> | 766 | + <Pagination className="mx-0 w-auto justify-end"> |
| 767 | + <PaginationContent> | ||
| 768 | + <PaginationItem> | ||
| 769 | + <PaginationPrevious | ||
| 770 | + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} | ||
| 771 | + onClick={() => pageIndex > 1 && setPageIndex((x) => Math.max(1, x - 1))} | ||
| 772 | + aria-disabled={pageIndex <= 1} | ||
| 773 | + /> | ||
| 774 | + </PaginationItem> | ||
| 775 | + <PaginationItem> | ||
| 776 | + <PaginationLink | ||
| 777 | + className="cursor-default" | ||
| 778 | + size="default" | ||
| 779 | + isActive | ||
| 780 | + onClick={(e) => e.preventDefault()} | ||
| 781 | + > | ||
| 782 | + Page {pageIndex} / {totalPages} | ||
| 783 | + </PaginationLink> | ||
| 784 | + </PaginationItem> | ||
| 785 | + <PaginationItem> | ||
| 786 | + <PaginationNext | ||
| 787 | + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} | ||
| 788 | + onClick={() => pageIndex < totalPages && setPageIndex((x) => Math.min(totalPages, x + 1))} | ||
| 789 | + aria-disabled={pageIndex >= totalPages} | ||
| 790 | + /> | ||
| 791 | + </PaginationItem> | ||
| 792 | + </PaginationContent> | ||
| 793 | + </Pagination> | ||
| 699 | </div> | 794 | </div> |
| 700 | </div> | 795 | </div> |
| 701 | </div> | 796 | </div> |
| @@ -846,8 +941,9 @@ export function ProductsView() { | @@ -846,8 +941,9 @@ export function ProductsView() { | ||
| 846 | 941 | ||
| 847 | <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3 shrink-0"> | 942 | <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3 shrink-0"> |
| 848 | <div className="text-sm text-gray-600"> | 943 | <div className="text-sm text-gray-600"> |
| 849 | - Showing {catTotal === 0 ? 0 : (catPageIndex - 1) * catPageSize + 1}- | ||
| 850 | - {Math.min(catPageIndex * catPageSize, catTotal)} of {catTotal} | 944 | + {catTotal === 0 |
| 945 | + ? "Showing 0 of 0" | ||
| 946 | + : `Showing ${(catPageIndex - 1) * catPageSize + 1}–${Math.min(catPageIndex * catPageSize, catTotal)} of ${catTotal}`} | ||
| 851 | </div> | 947 | </div> |
| 852 | <div className="flex items-center gap-3"> | 948 | <div className="flex items-center gap-3"> |
| 853 | <Select value={String(catPageSize)} onValueChange={(v) => setCatPageSize(Number(v))}> | 949 | <Select value={String(catPageSize)} onValueChange={(v) => setCatPageSize(Number(v))}> |
| @@ -927,6 +1023,43 @@ export function ProductsView() { | @@ -927,6 +1023,43 @@ export function ProductsView() { | ||
| 927 | onDeleted={refresh} | 1023 | onDeleted={refresh} |
| 928 | /> | 1024 | /> |
| 929 | 1025 | ||
| 1026 | + <BatchImportDialog | ||
| 1027 | + open={bulkImportOpen} | ||
| 1028 | + onOpenChange={setBulkImportOpen} | ||
| 1029 | + title="Bulk import products" | ||
| 1030 | + description="Upload an .xlsx file. Use the official template for column headers." | ||
| 1031 | + downloadingTemplate={tmplDownloading} | ||
| 1032 | + onDownloadTemplate={async () => { | ||
| 1033 | + setTmplDownloading(true); | ||
| 1034 | + try { | ||
| 1035 | + await downloadProductImportTemplate(); | ||
| 1036 | + toast.success("Template downloaded."); | ||
| 1037 | + } catch (e: unknown) { | ||
| 1038 | + const msg = e instanceof Error ? e.message : "Download failed."; | ||
| 1039 | + toast.error("Template download failed", { description: msg }); | ||
| 1040 | + } finally { | ||
| 1041 | + setTmplDownloading(false); | ||
| 1042 | + } | ||
| 1043 | + }} | ||
| 1044 | + onImportFile={async (file) => { | ||
| 1045 | + const r = await importProductsBatch(file); | ||
| 1046 | + refresh(); | ||
| 1047 | + reloadCategoryCatalog(); | ||
| 1048 | + return { successCount: r.successCount, failCount: r.failCount }; | ||
| 1049 | + }} | ||
| 1050 | + /> | ||
| 1051 | + | ||
| 1052 | + <ProductBulkEditDialog | ||
| 1053 | + open={bulkEditOpen} | ||
| 1054 | + onOpenChange={setBulkEditOpen} | ||
| 1055 | + seed={bulkEditSeed} | ||
| 1056 | + categories={productCategoriesCatalog} | ||
| 1057 | + onSaved={() => { | ||
| 1058 | + setSelectedProductIds(new Set()); | ||
| 1059 | + refresh(); | ||
| 1060 | + }} | ||
| 1061 | + /> | ||
| 1062 | + | ||
| 930 | <ProductCategoryFormDialog | 1063 | <ProductCategoryFormDialog |
| 931 | open={isProductCategoryDialogOpen} | 1064 | open={isProductCategoryDialogOpen} |
| 932 | category={editingProductCategory} | 1065 | category={editingProductCategory} |
美国版/Food Labeling Management Platform/src/components/products/product-bulk-edit-dialog.tsx
0 → 100644
| 1 | +import React, { useEffect, useMemo, useState } from "react"; | ||
| 2 | +import { Button } from "../ui/button"; | ||
| 3 | +import { Input } from "../ui/input"; | ||
| 4 | +import { Switch } from "../ui/switch"; | ||
| 5 | +import { | ||
| 6 | + Dialog, | ||
| 7 | + DialogContent, | ||
| 8 | + DialogDescription, | ||
| 9 | + DialogHeader, | ||
| 10 | + DialogTitle, | ||
| 11 | +} from "../ui/dialog"; | ||
| 12 | +import { | ||
| 13 | + Select, | ||
| 14 | + SelectContent, | ||
| 15 | + SelectItem, | ||
| 16 | + SelectTrigger, | ||
| 17 | + SelectValue, | ||
| 18 | +} from "../ui/select"; | ||
| 19 | +import { toast } from "sonner"; | ||
| 20 | +import { ApiError } from "../../lib/apiClient"; | ||
| 21 | +import { updateProductsBulk, type ProductBulkUpdateItemVo } from "../../services/productService"; | ||
| 22 | +import type { ProductDto } from "../../types/product"; | ||
| 23 | +import type { ProductCategoryDto } from "../../types/productCategory"; | ||
| 24 | + | ||
| 25 | +function isValidBulkProductId(id: string): boolean { | ||
| 26 | + return !!(id ?? "").trim(); | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +export type ProductBulkEditDialogProps = { | ||
| 30 | + open: boolean; | ||
| 31 | + onOpenChange: (open: boolean) => void; | ||
| 32 | + seed: ProductDto[]; | ||
| 33 | + categories: ProductCategoryDto[]; | ||
| 34 | + onSaved: () => void; | ||
| 35 | +}; | ||
| 36 | + | ||
| 37 | +type RowState = ProductBulkUpdateItemVo; | ||
| 38 | + | ||
| 39 | +function productToRow(p: ProductDto, locationIds: string[]): RowState { | ||
| 40 | + return { | ||
| 41 | + id: p.id, | ||
| 42 | + productCode: p.productCode ?? "", | ||
| 43 | + productName: (p.productName ?? "").trim(), | ||
| 44 | + categoryId: (p.categoryId ?? "").trim() || null, | ||
| 45 | + productImageUrl: p.productImageUrl ?? null, | ||
| 46 | + state: p.state !== false, | ||
| 47 | + locationIds: [...locationIds], | ||
| 48 | + }; | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +function emptyPadRow(): RowState { | ||
| 52 | + return { | ||
| 53 | + id: "", | ||
| 54 | + productCode: "", | ||
| 55 | + productName: "", | ||
| 56 | + categoryId: null, | ||
| 57 | + productImageUrl: null, | ||
| 58 | + state: true, | ||
| 59 | + locationIds: [], | ||
| 60 | + }; | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +function parseIdsCsv(s: string): string[] { | ||
| 64 | + return s | ||
| 65 | + .split(/[,;|\s]+/) | ||
| 66 | + .map((x) => x.trim()) | ||
| 67 | + .filter(Boolean); | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, onSaved }: ProductBulkEditDialogProps) { | ||
| 71 | + const [rows, setRows] = useState<RowState[]>([]); | ||
| 72 | + const [saving, setSaving] = useState(false); | ||
| 73 | + const [locCsvByIdx, setLocCsvByIdx] = useState<string[]>([]); | ||
| 74 | + | ||
| 75 | + const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]); | ||
| 76 | + | ||
| 77 | + useEffect(() => { | ||
| 78 | + if (!open) return; | ||
| 79 | + const data = seed.map((p) => productToRow(p, p.locationIds ?? [])); | ||
| 80 | + const pad = Math.max(0, minRows - data.length); | ||
| 81 | + const padded = [...data, ...Array.from({ length: pad }, () => emptyPadRow())]; | ||
| 82 | + setRows(padded); | ||
| 83 | + setLocCsvByIdx( | ||
| 84 | + padded.map((r) => (r.locationIds && r.locationIds.length ? r.locationIds.join(",") : "")), | ||
| 85 | + ); | ||
| 86 | + }, [open, seed, minRows]); | ||
| 87 | + | ||
| 88 | + const updateRow = (idx: number, patch: Partial<RowState>) => { | ||
| 89 | + setRows((prev) => { | ||
| 90 | + const next = [...prev]; | ||
| 91 | + next[idx] = { ...next[idx], ...patch }; | ||
| 92 | + return next; | ||
| 93 | + }); | ||
| 94 | + }; | ||
| 95 | + | ||
| 96 | + const removeRow = (idx: number) => { | ||
| 97 | + setRows((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx))); | ||
| 98 | + setLocCsvByIdx((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx))); | ||
| 99 | + }; | ||
| 100 | + | ||
| 101 | + const syncLocIds = (idx: number, csv: string) => { | ||
| 102 | + const nextCsv = [...locCsvByIdx]; | ||
| 103 | + nextCsv[idx] = csv; | ||
| 104 | + setLocCsvByIdx(nextCsv); | ||
| 105 | + updateRow(idx, { locationIds: parseIdsCsv(csv) }); | ||
| 106 | + }; | ||
| 107 | + | ||
| 108 | + const handleSave = async () => { | ||
| 109 | + const items: ProductBulkUpdateItemVo[] = rows | ||
| 110 | + .filter((r) => isValidBulkProductId(r.id)) | ||
| 111 | + .map((r, i) => ({ | ||
| 112 | + id: r.id.trim(), | ||
| 113 | + productCode: String(r.productCode ?? "").trim() || null, | ||
| 114 | + productName: r.productName.trim(), | ||
| 115 | + categoryId: r.categoryId?.trim() ? r.categoryId.trim() : null, | ||
| 116 | + productImageUrl: r.productImageUrl?.trim() ? r.productImageUrl.trim() : null, | ||
| 117 | + state: r.state !== false, | ||
| 118 | + locationIds: parseIdsCsv(locCsvByIdx[i] ?? ""), | ||
| 119 | + })); | ||
| 120 | + | ||
| 121 | + if (items.length === 0) { | ||
| 122 | + toast.error("No valid rows", { description: "Select products in the list first." }); | ||
| 123 | + return; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + setSaving(true); | ||
| 127 | + try { | ||
| 128 | + const res = await updateProductsBulk({ items }); | ||
| 129 | + toast.success("Bulk update finished", { | ||
| 130 | + description: `Success: ${res.successCount}, failed: ${res.failCount}`, | ||
| 131 | + }); | ||
| 132 | + onSaved(); | ||
| 133 | + onOpenChange(false); | ||
| 134 | + } catch (e) { | ||
| 135 | + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed."; | ||
| 136 | + toast.error("Bulk save failed", { description: msg }); | ||
| 137 | + } finally { | ||
| 138 | + setSaving(false); | ||
| 139 | + } | ||
| 140 | + }; | ||
| 141 | + | ||
| 142 | + return ( | ||
| 143 | + <Dialog open={open} onOpenChange={onOpenChange}> | ||
| 144 | + <DialogContent className="max-w-[min(96vw,1100px)] w-full max-h-[90vh] flex flex-col gap-0 p-0"> | ||
| 145 | + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0"> | ||
| 146 | + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> | ||
| 147 | + Back | ||
| 148 | + </Button> | ||
| 149 | + <DialogHeader className="flex-1 text-center space-y-0 py-0"> | ||
| 150 | + <DialogTitle className="text-base">Product bulk edit</DialogTitle> | ||
| 151 | + <DialogDescription className="sr-only">Edit products in a grid and save all.</DialogDescription> | ||
| 152 | + </DialogHeader> | ||
| 153 | + <Button type="button" className="bg-green-600 hover:bg-green-700 text-white shrink-0" disabled={saving} onClick={() => void handleSave()}> | ||
| 154 | + {saving ? "Saving…" : "Save All"} | ||
| 155 | + </Button> | ||
| 156 | + </div> | ||
| 157 | + <div className="overflow-auto flex-1 min-h-0 px-2 py-3"> | ||
| 158 | + <table className="w-full text-xs border-collapse border border-gray-200"> | ||
| 159 | + <thead className="bg-gray-100 sticky top-0 z-10"> | ||
| 160 | + <tr> | ||
| 161 | + <th className="border p-1 w-8" /> | ||
| 162 | + <th className="border p-1 whitespace-nowrap">Product Code</th> | ||
| 163 | + <th className="border p-1 whitespace-nowrap">Product *</th> | ||
| 164 | + <th className="border p-1 whitespace-nowrap">Category</th> | ||
| 165 | + <th className="border p-1 whitespace-nowrap">Image URL</th> | ||
| 166 | + <th className="border p-1 whitespace-nowrap">Location IDs</th> | ||
| 167 | + <th className="border p-1 whitespace-nowrap">Active</th> | ||
| 168 | + </tr> | ||
| 169 | + </thead> | ||
| 170 | + <tbody> | ||
| 171 | + {rows.map((r, idx) => ( | ||
| 172 | + <tr key={`${r.id || "e"}-${idx}`}> | ||
| 173 | + <td className="border p-0 text-center align-top"> | ||
| 174 | + <div className="flex flex-col items-center gap-1 py-1"> | ||
| 175 | + <span className="text-[10px] text-gray-500">{idx + 1}</span> | ||
| 176 | + <Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-1" onClick={() => removeRow(idx)}> | ||
| 177 | + × | ||
| 178 | + </Button> | ||
| 179 | + </div> | ||
| 180 | + </td> | ||
| 181 | + <td className="border p-1 align-top"> | ||
| 182 | + <Input className="h-7 text-xs" value={r.productCode ?? ""} onChange={(e) => updateRow(idx, { productCode: e.target.value })} /> | ||
| 183 | + </td> | ||
| 184 | + <td className="border p-1 align-top"> | ||
| 185 | + <Input className="h-7 text-xs min-w-[120px]" value={r.productName} onChange={(e) => updateRow(idx, { productName: e.target.value })} /> | ||
| 186 | + </td> | ||
| 187 | + <td className="border p-1 align-top min-w-[140px]"> | ||
| 188 | + <Select | ||
| 189 | + value={r.categoryId ?? "__none__"} | ||
| 190 | + onValueChange={(v) => updateRow(idx, { categoryId: v === "__none__" ? null : v })} | ||
| 191 | + > | ||
| 192 | + <SelectTrigger className="h-7 text-xs"> | ||
| 193 | + <SelectValue placeholder="Category" /> | ||
| 194 | + </SelectTrigger> | ||
| 195 | + <SelectContent> | ||
| 196 | + <SelectItem value="__none__">(none)</SelectItem> | ||
| 197 | + {categories.map((c) => ( | ||
| 198 | + <SelectItem key={c.id} value={c.id}> | ||
| 199 | + {c.categoryName ?? c.id} | ||
| 200 | + </SelectItem> | ||
| 201 | + ))} | ||
| 202 | + </SelectContent> | ||
| 203 | + </Select> | ||
| 204 | + </td> | ||
| 205 | + <td className="border p-1 align-top"> | ||
| 206 | + <Input | ||
| 207 | + className="h-7 text-xs min-w-[140px]" | ||
| 208 | + value={r.productImageUrl ?? ""} | ||
| 209 | + onChange={(e) => updateRow(idx, { productImageUrl: e.target.value || null })} | ||
| 210 | + /> | ||
| 211 | + </td> | ||
| 212 | + <td className="border p-1 align-top"> | ||
| 213 | + <Input | ||
| 214 | + className="h-7 text-xs min-w-[160px]" | ||
| 215 | + value={locCsvByIdx[idx] ?? ""} | ||
| 216 | + onChange={(e) => syncLocIds(idx, e.target.value)} | ||
| 217 | + placeholder="id1,id2" | ||
| 218 | + /> | ||
| 219 | + </td> | ||
| 220 | + <td className="border p-1 text-center align-middle"> | ||
| 221 | + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} /> | ||
| 222 | + </td> | ||
| 223 | + </tr> | ||
| 224 | + ))} | ||
| 225 | + </tbody> | ||
| 226 | + </table> | ||
| 227 | + </div> | ||
| 228 | + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1"> | ||
| 229 | + <p>Columns with * are required for saved rows.</p> | ||
| 230 | + <p>Location IDs: comma-separated location primary keys (GUID).</p> | ||
| 231 | + </div> | ||
| 232 | + </DialogContent> | ||
| 233 | + </Dialog> | ||
| 234 | + ); | ||
| 235 | +} |
美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx
| 1 | -import React, { useCallback, useEffect, useRef, useState } from "react"; | 1 | +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | +import type { DateRange } from "react-day-picker"; | ||
| 2 | import { Search, Download, Printer, Calendar as CalendarIcon, BarChart3, LineChart, ArrowUpRight, RefreshCw, FileText } from "lucide-react"; | 3 | import { Search, Download, Printer, Calendar as CalendarIcon, BarChart3, LineChart, ArrowUpRight, RefreshCw, FileText } from "lucide-react"; |
| 3 | import { Button } from "../ui/button"; | 4 | import { Button } from "../ui/button"; |
| 4 | import { Input } from "../ui/input"; | 5 | import { Input } from "../ui/input"; |
| @@ -16,11 +17,9 @@ import { getLocations } from "../../services/locationService"; | @@ -16,11 +17,9 @@ import { getLocations } from "../../services/locationService"; | ||
| 16 | import { getPartners } from "../../services/partnerService"; | 17 | import { getPartners } from "../../services/partnerService"; |
| 17 | import { getGroups } from "../../services/groupService"; | 18 | import { getGroups } from "../../services/groupService"; |
| 18 | import { | 19 | import { |
| 19 | - exportLabelReportPdf, | ||
| 20 | - exportPrintLogPdf, | 20 | + exportPrintLogExcel, |
| 21 | getLabelReport, | 21 | getLabelReport, |
| 22 | getReportsPrintLogList, | 22 | getReportsPrintLogList, |
| 23 | - reprintPrintLog, | ||
| 24 | } from "../../services/reportsService"; | 23 | } from "../../services/reportsService"; |
| 25 | import type { LocationDto } from "../../types/location"; | 24 | import type { LocationDto } from "../../types/location"; |
| 26 | import type { PartnerListItem } from "../../types/partner"; | 25 | import type { PartnerListItem } from "../../types/partner"; |
| @@ -98,6 +97,62 @@ function formatTrendLabel(iso: string): string { | @@ -98,6 +97,62 @@ function formatTrendLabel(iso: string): string { | ||
| 98 | return d.toLocaleDateString("en-US", { month: "numeric", day: "numeric" }); | 97 | return d.toLocaleDateString("en-US", { month: "numeric", day: "numeric" }); |
| 99 | } | 98 | } |
| 100 | 99 | ||
| 100 | +/** Reports 筛选:单弹层 + range 日历,避免双日历布局问题 */ | ||
| 101 | +function PeriodRangePicker({ | ||
| 102 | + startDate, | ||
| 103 | + endDate, | ||
| 104 | + onRangeChange, | ||
| 105 | +}: { | ||
| 106 | + startDate: string; | ||
| 107 | + endDate: string; | ||
| 108 | + onRangeChange: (start: string, end: string) => void; | ||
| 109 | +}) { | ||
| 110 | + const [open, setOpen] = useState(false); | ||
| 111 | + const selectedRange: DateRange | undefined = useMemo(() => { | ||
| 112 | + const from = parseIsoDate(startDate); | ||
| 113 | + const to = parseIsoDate(endDate); | ||
| 114 | + if (from && to) return { from, to }; | ||
| 115 | + if (from) return { from, to: undefined }; | ||
| 116 | + return undefined; | ||
| 117 | + }, [startDate, endDate]); | ||
| 118 | + | ||
| 119 | + const label = `${startDate || "YYYY-MM-DD"} — ${endDate || "YYYY-MM-DD"}`; | ||
| 120 | + | ||
| 121 | + return ( | ||
| 122 | + <div className="flex items-center gap-2 shrink-0" lang="en-US"> | ||
| 123 | + <span className="text-sm font-medium text-gray-700">Period Search:</span> | ||
| 124 | + <Popover open={open} onOpenChange={setOpen}> | ||
| 125 | + <PopoverTrigger asChild> | ||
| 126 | + <Button | ||
| 127 | + type="button" | ||
| 128 | + variant="outline" | ||
| 129 | + className="h-10 min-w-[17rem] justify-start gap-2 border border-gray-300 bg-white px-3 text-sm font-mono tabular-nums text-gray-900 hover:bg-gray-50" | ||
| 130 | + > | ||
| 131 | + <CalendarIcon className="h-4 w-4 shrink-0 text-gray-500" aria-hidden /> | ||
| 132 | + {label} | ||
| 133 | + </Button> | ||
| 134 | + </PopoverTrigger> | ||
| 135 | + <PopoverContent className="w-auto p-0" align="start"> | ||
| 136 | + <Calendar | ||
| 137 | + mode="range" | ||
| 138 | + numberOfMonths={1} | ||
| 139 | + defaultMonth={parseIsoDate(startDate) ?? parseIsoDate(endDate) ?? new Date()} | ||
| 140 | + selected={selectedRange} | ||
| 141 | + onSelect={(range) => { | ||
| 142 | + if (!range?.from) return; | ||
| 143 | + const s = formatIsoDate(range.from); | ||
| 144 | + const e = range.to ? formatIsoDate(range.to) : s; | ||
| 145 | + onRangeChange(s, e); | ||
| 146 | + if (range.from && range.to) setOpen(false); | ||
| 147 | + }} | ||
| 148 | + initialFocus | ||
| 149 | + /> | ||
| 150 | + </PopoverContent> | ||
| 151 | + </Popover> | ||
| 152 | + </div> | ||
| 153 | + ); | ||
| 154 | +} | ||
| 155 | + | ||
| 101 | function templateCell(template: string) { | 156 | function templateCell(template: string) { |
| 102 | const t = template ?? ""; | 157 | const t = template ?? ""; |
| 103 | if (!t.trim()) return <span className="text-gray-500">None</span>; | 158 | if (!t.trim()) return <span className="text-gray-500">None</span>; |
| @@ -169,7 +224,6 @@ export function ReportsView({ | @@ -169,7 +224,6 @@ export function ReportsView({ | ||
| 169 | const [labelData, setLabelData] = useState<LabelReportData | null>(null); | 224 | const [labelData, setLabelData] = useState<LabelReportData | null>(null); |
| 170 | const [labelLoading, setLabelLoading] = useState(false); | 225 | const [labelLoading, setLabelLoading] = useState(false); |
| 171 | const [exporting, setExporting] = useState(false); | 226 | const [exporting, setExporting] = useState(false); |
| 172 | - const [reprintBusyId, setReprintBusyId] = useState<string | null>(null); | ||
| 173 | const [filterMetaLoading, setFilterMetaLoading] = useState(true); | 227 | const [filterMetaLoading, setFilterMetaLoading] = useState(true); |
| 174 | 228 | ||
| 175 | const printAbortRef = useRef<AbortController | null>(null); | 229 | const printAbortRef = useRef<AbortController | null>(null); |
| @@ -350,41 +404,17 @@ export function ReportsView({ | @@ -350,41 +404,17 @@ export function ReportsView({ | ||
| 350 | setLocationId(ALL); | 404 | setLocationId(ALL); |
| 351 | }; | 405 | }; |
| 352 | 406 | ||
| 353 | - const handleReprint = async (row: ReportsPrintLogListItem) => { | ||
| 354 | - const loc = (row.locationId ?? "").trim(); | ||
| 355 | - const task = (row.taskId ?? "").trim(); | ||
| 356 | - if (!loc || !task) { | ||
| 357 | - toast.error("Cannot reprint", { description: "Missing location or task id." }); | ||
| 358 | - return; | ||
| 359 | - } | ||
| 360 | - const key = task; | ||
| 361 | - setReprintBusyId(key); | ||
| 362 | - try { | ||
| 363 | - await reprintPrintLog({ locationId: loc, taskId: task, printQuantity: 1 }); | ||
| 364 | - toast.success("Reprint request sent", { description: `Task ${row.labelCode || task}` }); | ||
| 365 | - } catch (e) { | ||
| 366 | - const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again."; | ||
| 367 | - toast.error("Reprint failed", { description: msg }); | ||
| 368 | - } finally { | ||
| 369 | - setReprintBusyId(null); | ||
| 370 | - } | ||
| 371 | - }; | ||
| 372 | - | ||
| 373 | const handleExport = async () => { | 407 | const handleExport = async () => { |
| 374 | const f = buildReportFilters(); | 408 | const f = buildReportFilters(); |
| 375 | setExporting(true); | 409 | setExporting(true); |
| 376 | try { | 410 | try { |
| 377 | - if (activeTab === "print-log") { | ||
| 378 | - await exportPrintLogPdf({ | ||
| 379 | - ...f, | ||
| 380 | - skipCount: 1, | ||
| 381 | - maxResultCount: 10, | ||
| 382 | - sorting: "PrintedAt desc", | ||
| 383 | - }); | ||
| 384 | - } else { | ||
| 385 | - await exportLabelReportPdf(f); | ||
| 386 | - } | ||
| 387 | - toast.success("Export ready", { description: "The PDF download should start shortly." }); | 411 | + await exportPrintLogExcel({ |
| 412 | + ...f, | ||
| 413 | + skipCount: 1, | ||
| 414 | + maxResultCount: 10, | ||
| 415 | + sorting: "PrintedAt desc", | ||
| 416 | + }); | ||
| 417 | + toast.success("Export ready", { description: "The Excel download should start shortly." }); | ||
| 388 | } catch (e) { | 418 | } catch (e) { |
| 389 | const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again."; | 419 | const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again."; |
| 390 | toast.error("Export failed", { description: msg }); | 420 | toast.error("Export failed", { description: msg }); |
| @@ -445,57 +475,14 @@ export function ReportsView({ | @@ -445,57 +475,14 @@ export function ReportsView({ | ||
| 445 | ))} | 475 | ))} |
| 446 | </SelectContent> | 476 | </SelectContent> |
| 447 | </Select> | 477 | </Select> |
| 448 | - <div className="flex items-center gap-2 shrink-0" lang="en-US"> | ||
| 449 | - <span className="text-sm font-medium text-gray-700">Period Search:</span> | ||
| 450 | - <div | ||
| 451 | - className="flex items-center bg-white border border-gray-300 rounded-md h-10 px-2" | ||
| 452 | - style={{ minHeight: 40 }} | ||
| 453 | - lang="en-US" | ||
| 454 | - > | ||
| 455 | - <CalendarIcon className="w-4 h-4 text-gray-500 mr-2 shrink-0" aria-hidden /> | ||
| 456 | - <Popover> | ||
| 457 | - <PopoverTrigger asChild> | ||
| 458 | - <Button | ||
| 459 | - type="button" | ||
| 460 | - variant="ghost" | ||
| 461 | - className="h-8 w-[10.5rem] justify-start px-0 text-sm font-mono tabular-nums hover:bg-transparent" | ||
| 462 | - > | ||
| 463 | - {startDate || "YYYY-MM-DD"} | ||
| 464 | - </Button> | ||
| 465 | - </PopoverTrigger> | ||
| 466 | - <PopoverContent className="w-auto p-0" align="start"> | ||
| 467 | - <Calendar | ||
| 468 | - mode="single" | ||
| 469 | - selected={parseIsoDate(startDate)} | ||
| 470 | - onSelect={(d) => d && setStartDate(formatIsoDate(d))} | ||
| 471 | - initialFocus | ||
| 472 | - /> | ||
| 473 | - </PopoverContent> | ||
| 474 | - </Popover> | ||
| 475 | - <span className="mx-2 text-gray-400" aria-hidden> | ||
| 476 | - - | ||
| 477 | - </span> | ||
| 478 | - <Popover> | ||
| 479 | - <PopoverTrigger asChild> | ||
| 480 | - <Button | ||
| 481 | - type="button" | ||
| 482 | - variant="ghost" | ||
| 483 | - className="h-8 w-[10.5rem] justify-start px-0 text-sm font-mono tabular-nums hover:bg-transparent" | ||
| 484 | - > | ||
| 485 | - {endDate || "YYYY-MM-DD"} | ||
| 486 | - </Button> | ||
| 487 | - </PopoverTrigger> | ||
| 488 | - <PopoverContent className="w-auto p-0" align="start"> | ||
| 489 | - <Calendar | ||
| 490 | - mode="single" | ||
| 491 | - selected={parseIsoDate(endDate)} | ||
| 492 | - onSelect={(d) => d && setEndDate(formatIsoDate(d))} | ||
| 493 | - initialFocus | ||
| 494 | - /> | ||
| 495 | - </PopoverContent> | ||
| 496 | - </Popover> | ||
| 497 | - </div> | ||
| 498 | - </div> | 478 | + <PeriodRangePicker |
| 479 | + startDate={startDate} | ||
| 480 | + endDate={endDate} | ||
| 481 | + onRangeChange={(start, end) => { | ||
| 482 | + setStartDate(start); | ||
| 483 | + setEndDate(end); | ||
| 484 | + }} | ||
| 485 | + /> | ||
| 499 | <div | 486 | <div |
| 500 | className="flex items-center w-64 rounded-md border border-gray-300 bg-white overflow-hidden shrink-0" | 487 | className="flex items-center w-64 rounded-md border border-gray-300 bg-white overflow-hidden shrink-0" |
| 501 | style={{ height: 40 }} | 488 | style={{ height: 40 }} |
| @@ -509,15 +496,17 @@ export function ReportsView({ | @@ -509,15 +496,17 @@ export function ReportsView({ | ||
| 509 | /> | 496 | /> |
| 510 | </div> | 497 | </div> |
| 511 | <div className="flex-1 min-w-2" /> | 498 | <div className="flex-1 min-w-2" /> |
| 512 | - <Button | ||
| 513 | - type="button" | ||
| 514 | - variant="outline" | ||
| 515 | - className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" | ||
| 516 | - disabled={exporting} | ||
| 517 | - onClick={() => void handleExport()} | ||
| 518 | - > | ||
| 519 | - <Download className="w-4 h-4" /> {exporting ? "Exporting…" : "Export Report"} | ||
| 520 | - </Button> | 499 | + {activeTab === "print-log" && ( |
| 500 | + <Button | ||
| 501 | + type="button" | ||
| 502 | + variant="outline" | ||
| 503 | + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" | ||
| 504 | + disabled={exporting} | ||
| 505 | + onClick={() => void handleExport()} | ||
| 506 | + > | ||
| 507 | + <Download className="w-4 h-4" /> {exporting ? "Exporting…" : "Export Report"} | ||
| 508 | + </Button> | ||
| 509 | + )} | ||
| 521 | </div> | 510 | </div> |
| 522 | 511 | ||
| 523 | <div className="w-full border-b border-gray-200 mt-4"> | 512 | <div className="w-full border-b border-gray-200 mt-4"> |
| @@ -599,17 +588,13 @@ export function ReportsView({ | @@ -599,17 +588,13 @@ export function ReportsView({ | ||
| 599 | <TableCell className="border-r text-gray-600 text-sm font-numeric">{toDisplay(log.locationText)}</TableCell> | 588 | <TableCell className="border-r text-gray-600 text-sm font-numeric">{toDisplay(log.locationText)}</TableCell> |
| 600 | <TableCell className="border-r text-sm font-mono text-gray-800">{toDisplay(log.expiryDateText)}</TableCell> | 589 | <TableCell className="border-r text-sm font-mono text-gray-800">{toDisplay(log.expiryDateText)}</TableCell> |
| 601 | <TableCell className="text-center"> | 590 | <TableCell className="text-center"> |
| 602 | - <Button | ||
| 603 | - type="button" | ||
| 604 | - size="sm" | ||
| 605 | - variant="outline" | ||
| 606 | - className="h-8 gap-1 hover:bg-gray-100 border-gray-300" | ||
| 607 | - disabled={reprintBusyId === (log.taskId || "")} | ||
| 608 | - onClick={() => void handleReprint(log)} | 591 | + <div |
| 592 | + className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-gray-300 bg-white px-4 text-sm font-medium text-gray-900 shadow-sm select-none pointer-events-none" | ||
| 593 | + aria-label="Reprint" | ||
| 609 | > | 594 | > |
| 610 | - <Printer className="w-3 h-3" /> | ||
| 611 | - {reprintBusyId === (log.taskId || "") ? "…" : "Reprint"} | ||
| 612 | - </Button> | 595 | + <Printer className="w-3 h-3 shrink-0 text-gray-700" aria-hidden /> |
| 596 | + <span>Reprint</span> | ||
| 597 | + </div> | ||
| 613 | </TableCell> | 598 | </TableCell> |
| 614 | </TableRow> | 599 | </TableRow> |
| 615 | ))} | 600 | ))} |
| @@ -633,7 +618,12 @@ export function ReportsView({ | @@ -633,7 +618,12 @@ export function ReportsView({ | ||
| 633 | /> | 618 | /> |
| 634 | </PaginationItem> | 619 | </PaginationItem> |
| 635 | <PaginationItem> | 620 | <PaginationItem> |
| 636 | - <PaginationLink className="cursor-default" isActive onClick={(e) => e.preventDefault()}> | 621 | + <PaginationLink |
| 622 | + className="cursor-default" | ||
| 623 | + size="default" | ||
| 624 | + isActive | ||
| 625 | + onClick={(e) => e.preventDefault()} | ||
| 626 | + > | ||
| 637 | Page {pageIndex} / {totalPages} | 627 | Page {pageIndex} / {totalPages} |
| 638 | </PaginationLink> | 628 | </PaginationLink> |
| 639 | </PaginationItem> | 629 | </PaginationItem> |
美国版/Food Labeling Management Platform/src/components/ui/calendar.tsx
| @@ -18,7 +18,7 @@ function Calendar({ | @@ -18,7 +18,7 @@ function Calendar({ | ||
| 18 | showOutsideDays={showOutsideDays} | 18 | showOutsideDays={showOutsideDays} |
| 19 | className={cn("p-3", className)} | 19 | className={cn("p-3", className)} |
| 20 | classNames={{ | 20 | classNames={{ |
| 21 | - months: "flex flex-col sm:flex-row gap-2", | 21 | + months: "flex flex-col gap-4 sm:flex-row sm:gap-2", |
| 22 | month: "flex flex-col gap-4", | 22 | month: "flex flex-col gap-4", |
| 23 | caption: "flex justify-center pt-1 relative items-center w-full", | 23 | caption: "flex justify-center pt-1 relative items-center w-full", |
| 24 | caption_label: "text-sm font-medium", | 24 | caption_label: "text-sm font-medium", |
| @@ -29,20 +29,21 @@ function Calendar({ | @@ -29,20 +29,21 @@ function Calendar({ | ||
| 29 | ), | 29 | ), |
| 30 | nav_button_previous: "absolute left-1", | 30 | nav_button_previous: "absolute left-1", |
| 31 | nav_button_next: "absolute right-1", | 31 | nav_button_next: "absolute right-1", |
| 32 | - table: "w-full border-collapse space-x-1", | ||
| 33 | - head_row: "flex", | 32 | + /** 使用表格行布局;勿对 tr 使用 flex,否则日期格会挤成一串 */ |
| 33 | + table: "w-full border-collapse", | ||
| 34 | + head_row: "", | ||
| 34 | head_cell: | 35 | head_cell: |
| 35 | - "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", | ||
| 36 | - row: "flex w-full mt-2", | 36 | + "text-muted-foreground w-9 text-center text-[0.8rem] font-normal p-0 align-middle", |
| 37 | + row: "mt-2", | ||
| 37 | cell: cn( | 38 | cell: cn( |
| 38 | - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md", | 39 | + "relative p-0 text-center text-sm align-middle focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md", |
| 39 | props.mode === "range" | 40 | props.mode === "range" |
| 40 | ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" | 41 | ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" |
| 41 | : "[&:has([aria-selected])]:rounded-md", | 42 | : "[&:has([aria-selected])]:rounded-md", |
| 42 | ), | 43 | ), |
| 43 | day: cn( | 44 | day: cn( |
| 44 | buttonVariants({ variant: "ghost" }), | 45 | buttonVariants({ variant: "ghost" }), |
| 45 | - "size-8 p-0 font-normal aria-selected:opacity-100", | 46 | + "h-9 w-9 p-0 font-normal aria-selected:opacity-100", |
| 46 | ), | 47 | ), |
| 47 | day_range_start: | 48 | day_range_start: |
| 48 | "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", | 49 | "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", |
美国版/Food Labeling Management Platform/src/components/ui/image-url-upload.tsx
| @@ -72,8 +72,11 @@ export function ImageUrlUpload({ | @@ -72,8 +72,11 @@ export function ImageUrlUpload({ | ||
| 72 | if (!busy) inputRef.current?.click(); | 72 | if (!busy) inputRef.current?.click(); |
| 73 | }; | 73 | }; |
| 74 | 74 | ||
| 75 | - const boxBase = | ||
| 76 | - "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"; | 75 | + /** 未传 boxClassName 时用默认「自适应宽度 + 正方形」;传入 boxClassName 时不再附带 w-full/aspect,避免与固定宽高冲突 */ |
| 76 | + const hasCustomBox = Boolean(boxClassName?.trim()); | ||
| 77 | + const boxShell = hasCustomBox | ||
| 78 | + ? "rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" | ||
| 79 | + : "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"; | ||
| 77 | 80 | ||
| 78 | return ( | 81 | return ( |
| 79 | <div className={cn("space-y-2", className)}> | 82 | <div className={cn("space-y-2", className)}> |
| @@ -94,14 +97,14 @@ export function ImageUrlUpload({ | @@ -94,14 +97,14 @@ export function ImageUrlUpload({ | ||
| 94 | onClick={openPicker} | 97 | onClick={openPicker} |
| 95 | aria-label={emptyLabel || "Upload image"} | 98 | aria-label={emptyLabel || "Upload image"} |
| 96 | className={cn( | 99 | className={cn( |
| 97 | - boxBase, | 100 | + boxShell, |
| 101 | + hasCustomBox ? boxClassName : null, | ||
| 98 | "flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400", | 102 | "flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400", |
| 99 | emptyLabel && !uploading | 103 | emptyLabel && !uploading |
| 100 | ? "flex-col items-center justify-center gap-2" | 104 | ? "flex-col items-center justify-center gap-2" |
| 101 | : "items-center justify-center", | 105 | : "items-center justify-center", |
| 102 | "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500", | 106 | "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500", |
| 103 | "disabled:pointer-events-none disabled:opacity-50", | 107 | "disabled:pointer-events-none disabled:opacity-50", |
| 104 | - boxClassName, | ||
| 105 | )} | 108 | )} |
| 106 | > | 109 | > |
| 107 | {uploading ? ( | 110 | {uploading ? ( |
| @@ -120,9 +123,9 @@ export function ImageUrlUpload({ | @@ -120,9 +123,9 @@ export function ImageUrlUpload({ | ||
| 120 | ) : ( | 123 | ) : ( |
| 121 | <div | 124 | <div |
| 122 | className={cn( | 125 | className={cn( |
| 123 | - "group relative overflow-hidden rounded-md border-2 border-dashed border-gray-300 bg-gray-50/80", | ||
| 124 | - boxBase, | ||
| 125 | - boxClassName, | 126 | + "group relative overflow-hidden border-2 border-dashed border-gray-300 bg-gray-50/80", |
| 127 | + boxShell, | ||
| 128 | + hasCustomBox ? boxClassName : null, | ||
| 126 | )} | 129 | )} |
| 127 | > | 130 | > |
| 128 | <button | 131 | <button |
美国版/Food Labeling Management Platform/src/lib/batchFileHttp.ts
0 → 100644
| 1 | +import { ApiError } from "./apiClient"; | ||
| 2 | + | ||
| 3 | +const API_PREFIX = "/api/app"; | ||
| 4 | + | ||
| 5 | +function joinUrl(baseUrl: string, path: string): string { | ||
| 6 | + if (!baseUrl) return path; | ||
| 7 | + const b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; | ||
| 8 | + const p = path.startsWith("/") ? path : `/${path}`; | ||
| 9 | + return `${b}${p}`; | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +function toQueryString(params: Record<string, unknown>): string { | ||
| 13 | + const qs = new URLSearchParams(); | ||
| 14 | + for (const [k, v] of Object.entries(params)) { | ||
| 15 | + if (v === undefined || v === null || v === "") continue; | ||
| 16 | + if (typeof v === "boolean") { | ||
| 17 | + qs.set(k, v ? "true" : "false"); | ||
| 18 | + continue; | ||
| 19 | + } | ||
| 20 | + qs.set(k, String(v)); | ||
| 21 | + } | ||
| 22 | + const s = qs.toString(); | ||
| 23 | + return s ? `?${s}` : ""; | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +function getTokenForFetch(): string | null { | ||
| 27 | + try { | ||
| 28 | + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null; | ||
| 29 | + } catch { | ||
| 30 | + return null; | ||
| 31 | + } | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +function parseFileNameFromContentDisposition(h: string | null): string | null { | ||
| 35 | + if (!h) return null; | ||
| 36 | + const m = /filename\*?=(?:UTF-8''|)([^;]+)/i.exec(h); | ||
| 37 | + if (m?.[1]) return decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, "")); | ||
| 38 | + return null; | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +function getAbpErrorMessage(payload: unknown): string | null { | ||
| 42 | + if (!payload || typeof payload !== "object") return null; | ||
| 43 | + const p = payload as { error?: { message?: string } }; | ||
| 44 | + return p.error?.message?.trim() || null; | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +function unwrapEnvelope<T>(payload: unknown): T { | ||
| 48 | + if (!payload || typeof payload !== "object") return payload as T; | ||
| 49 | + const w = payload as Record<string, unknown>; | ||
| 50 | + if ("data" in w && w.data !== undefined) { | ||
| 51 | + if (w.succeeded === false) { | ||
| 52 | + const msg = | ||
| 53 | + (typeof (w.error as { message?: string } | undefined)?.message === "string" | ||
| 54 | + ? (w.error as { message: string }).message.trim() | ||
| 55 | + : "") || | ||
| 56 | + getAbpErrorMessage(payload) || | ||
| 57 | + "Request failed."; | ||
| 58 | + throw new ApiError(msg, typeof w.statusCode === "number" ? w.statusCode : 400, payload); | ||
| 59 | + } | ||
| 60 | + return w.data as T; | ||
| 61 | + } | ||
| 62 | + return payload as T; | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +/** | ||
| 66 | + * GET 下载二进制(Excel / PDF / 模板),触发浏览器保存。 | ||
| 67 | + */ | ||
| 68 | +export async function authorizedGetBlobDownload(opts: { | ||
| 69 | + path: string; | ||
| 70 | + query?: Record<string, unknown>; | ||
| 71 | + defaultFileName: string; | ||
| 72 | + signal?: AbortSignal; | ||
| 73 | +}): Promise<void> { | ||
| 74 | + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; | ||
| 75 | + const url = joinUrl(baseUrl, `${API_PREFIX}${opts.path}${toQueryString(opts.query ?? {})}`); | ||
| 76 | + const token = getTokenForFetch(); | ||
| 77 | + const res = await fetch(url, { | ||
| 78 | + method: "GET", | ||
| 79 | + headers: token ? { Authorization: `Bearer ${token}` } : {}, | ||
| 80 | + signal: opts.signal, | ||
| 81 | + }); | ||
| 82 | + const ct = res.headers.get("content-type") ?? ""; | ||
| 83 | + if (!res.ok) { | ||
| 84 | + if (ct.includes("application/json")) { | ||
| 85 | + const payload = await res.json().catch(() => null); | ||
| 86 | + const msg = getAbpErrorMessage(payload) || "Download failed."; | ||
| 87 | + throw new ApiError(msg, res.status, payload); | ||
| 88 | + } | ||
| 89 | + const t = await res.text().catch(() => ""); | ||
| 90 | + throw new ApiError(t || "Download failed.", res.status, t); | ||
| 91 | + } | ||
| 92 | + if (ct.includes("application/json")) { | ||
| 93 | + const payload = await res.json().catch(() => null); | ||
| 94 | + const msg = getAbpErrorMessage(payload) || "Download failed."; | ||
| 95 | + throw new ApiError(msg, res.status, payload); | ||
| 96 | + } | ||
| 97 | + const blob = await res.blob(); | ||
| 98 | + const name = parseFileNameFromContentDisposition(res.headers.get("content-disposition")) || opts.defaultFileName; | ||
| 99 | + const href = URL.createObjectURL(blob); | ||
| 100 | + const a = document.createElement("a"); | ||
| 101 | + a.href = href; | ||
| 102 | + a.download = name; | ||
| 103 | + a.click(); | ||
| 104 | + URL.revokeObjectURL(href); | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +/** | ||
| 108 | + * multipart 上传文件,解析 JSON 响应(兼容 ABP 包裹 `{ data, succeeded }`)。 | ||
| 109 | + */ | ||
| 110 | +export async function authorizedPostMultipartJson<T>(opts: { | ||
| 111 | + path: string; | ||
| 112 | + fieldName: string; | ||
| 113 | + file: File; | ||
| 114 | + signal?: AbortSignal; | ||
| 115 | +}): Promise<T> { | ||
| 116 | + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; | ||
| 117 | + const url = joinUrl(baseUrl, `${API_PREFIX}${opts.path}`); | ||
| 118 | + const token = getTokenForFetch(); | ||
| 119 | + const fd = new FormData(); | ||
| 120 | + fd.append(opts.fieldName, opts.file); | ||
| 121 | + const headers: Record<string, string> = {}; | ||
| 122 | + if (token) headers.Authorization = `Bearer ${token}`; | ||
| 123 | + const res = await fetch(url, { method: "POST", headers, body: fd, signal: opts.signal }); | ||
| 124 | + const ct = res.headers.get("content-type") ?? ""; | ||
| 125 | + const payload = ct.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => ""); | ||
| 126 | + if (!res.ok) { | ||
| 127 | + const msg = | ||
| 128 | + (typeof payload === "object" && payload && getAbpErrorMessage(payload)) || | ||
| 129 | + (typeof payload === "string" && payload.trim()) || | ||
| 130 | + "Upload failed."; | ||
| 131 | + throw new ApiError(msg, res.status, payload); | ||
| 132 | + } | ||
| 133 | + if (typeof payload !== "object" || payload === null) { | ||
| 134 | + throw new ApiError("Invalid import response.", res.status, payload); | ||
| 135 | + } | ||
| 136 | + return unwrapEnvelope<T>(payload); | ||
| 137 | +} |
美国版/Food Labeling Management Platform/src/lib/labelFormDatePreview.ts
| @@ -98,6 +98,24 @@ export function formatDateByPreset(format: string, date: Date): string { | @@ -98,6 +98,24 @@ export function formatDateByPreset(format: string, date: Date): string { | ||
| 98 | 98 | ||
| 99 | const OFFSET_UNIT_SET = new Set<string>(LABEL_FORM_OFFSET_UNITS as unknown as string[]); | 99 | const OFFSET_UNIT_SET = new Set<string>(LABEL_FORM_OFFSET_UNITS as unknown as string[]); |
| 100 | 100 | ||
| 101 | +export type NormalizedLabelFormOffset = | ||
| 102 | + | { kind: "zero" } | ||
| 103 | + | { kind: "amount"; amount: number; storeValue: string } | ||
| 104 | + | { kind: "invalid" }; | ||
| 105 | + | ||
| 106 | +/** | ||
| 107 | + * 创建/编辑标签表单:日期时间类偏移的**空串**或**数值 0**(含 0、0.0、+0 等)均视为相对基准偏移 0; | ||
| 108 | + * 预览即「当前基准时刻」;落库建议用 {@link serializePrintInputOffset}(unit, "0")。 | ||
| 109 | + */ | ||
| 110 | +export function normalizeLabelFormOffsetInput(valueRaw: string | undefined): NormalizedLabelFormOffset { | ||
| 111 | + const t = String(valueRaw ?? "").trim(); | ||
| 112 | + if (t === "") return { kind: "zero" }; | ||
| 113 | + const n = Number(t); | ||
| 114 | + if (!Number.isFinite(n)) return { kind: "invalid" }; | ||
| 115 | + if (n === 0) return { kind: "zero" }; | ||
| 116 | + return { kind: "amount", amount: n, storeValue: t }; | ||
| 117 | +} | ||
| 118 | + | ||
| 101 | /** | 119 | /** |
| 102 | * 模板 productDefault 中日期/时间/时长录入:存 `{"unit":"Days","value":"2"}`,供 App 与打印按 BaseTime 解析。 | 120 | * 模板 productDefault 中日期/时间/时长录入:存 `{"unit":"Days","value":"2"}`,供 App 与打印按 BaseTime 解析。 |
| 103 | */ | 121 | */ |
| @@ -146,9 +164,10 @@ export function resolveStoredPrintValueToDisplayText( | @@ -146,9 +164,10 @@ export function resolveStoredPrintValueToDisplayText( | ||
| 146 | if (!isDateTimeDataEntryField(el)) return s; | 164 | if (!isDateTimeDataEntryField(el)) return s; |
| 147 | const parsed = tryParsePrintInputOffsetStored(s); | 165 | const parsed = tryParsePrintInputOffsetStored(s); |
| 148 | if (!parsed) return s; | 166 | if (!parsed) return s; |
| 149 | - const amount = Number(String(parsed.value).trim()); | ||
| 150 | const unit = parsed.unit || "Days"; | 167 | const unit = parsed.unit || "Days"; |
| 151 | - if (!Number.isFinite(amount) || String(parsed.value).trim() === "") return ""; | 168 | + const norm = normalizeLabelFormOffsetInput(parsed.value); |
| 169 | + if (norm.kind === "invalid") return s; | ||
| 170 | + const amount = norm.kind === "zero" ? 0 : norm.amount; | ||
| 152 | const type = canonicalElementType(el.type); | 171 | const type = canonicalElementType(el.type); |
| 153 | const d = applyOffsetToDate(base, amount, unit); | 172 | const d = applyOffsetToDate(base, amount, unit); |
| 154 | const cfg = el.config as Record<string, unknown>; | 173 | const cfg = el.config as Record<string, unknown>; |
美国版/Food Labeling Management Platform/src/lib/nutritionManualEntry.ts
0 → 100644
| 1 | +import type { LabelElement } from "../types/labelTemplate"; | ||
| 2 | +import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate"; | ||
| 3 | + | ||
| 4 | +/** 批量表 / 与 elementId 拼接的字段名分隔(避免与普通 element id 冲突) */ | ||
| 5 | +export const NUTRITION_FIELD_COMPOSITE_SEP = "###nut###"; | ||
| 6 | + | ||
| 7 | +export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: string): string { | ||
| 8 | + return `${nutritionElementId}${NUTRITION_FIELD_COMPOSITE_SEP}${subKey}`; | ||
| 9 | +} | ||
| 10 | + | ||
| 11 | +export type NutritionManualFieldSpec = { | ||
| 12 | + subKey: string; | ||
| 13 | + columnLabel: string; | ||
| 14 | +}; | ||
| 15 | + | ||
| 16 | +function nutritionExtraRowsFromCfg(cfg: Record<string, unknown>): Array<{ | ||
| 17 | + id: string; | ||
| 18 | + name: string; | ||
| 19 | + value: string; | ||
| 20 | + unit: string; | ||
| 21 | +}> { | ||
| 22 | + const raw = cfg.extraNutrients; | ||
| 23 | + if (!Array.isArray(raw)) return []; | ||
| 24 | + return raw.map((item, idx) => { | ||
| 25 | + const row = item as Record<string, unknown>; | ||
| 26 | + return { | ||
| 27 | + id: String(row.id ?? `extra-${idx}`), | ||
| 28 | + name: String(row.name ?? ""), | ||
| 29 | + value: String(row.value ?? ""), | ||
| 30 | + unit: String(row.unit ?? ""), | ||
| 31 | + }; | ||
| 32 | + }); | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +function fixedLabelForKey(key: string): string { | ||
| 36 | + const hit = NUTRITION_FIXED_ITEMS.find((x) => x.key === key); | ||
| 37 | + return hit?.label ?? key; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +function readFixedValueFromCfg(cfg: Record<string, unknown>, key: string): string { | ||
| 41 | + const direct = cfg[key]; | ||
| 42 | + if (direct != null && String(direct).trim() !== "") return String(direct).trim(); | ||
| 43 | + const fixedRows = Array.isArray(cfg.fixedNutrients) | ||
| 44 | + ? (cfg.fixedNutrients as Record<string, unknown>[]) | ||
| 45 | + : []; | ||
| 46 | + const row = fixedRows.find((item) => String(item.key ?? "").trim() === key); | ||
| 47 | + return String(row?.value ?? "").trim(); | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +/** 与 LabelCanvas 营养成分展示一致:Calories 行是否应出现 */ | ||
| 51 | +function templateCaloriesDisplay(cfg: Record<string, unknown>): string { | ||
| 52 | + return String(cfg.calories ?? cfg.Calories ?? readFixedValueFromCfg(cfg, "calories") ?? "").trim(); | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +function templateServingsPerContainer(cfg: Record<string, unknown>): string { | ||
| 56 | + return String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? "").trim(); | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +function templateServingSize(cfg: Record<string, unknown>): string { | ||
| 60 | + return String(cfg.servingSize ?? cfg.ServingSize ?? "").trim(); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +function fakeNutritionElement(cfg: Record<string, unknown>): LabelElement { | ||
| 64 | + return { | ||
| 65 | + id: "__nutrition_cfg__", | ||
| 66 | + type: "NUTRITION", | ||
| 67 | + x: 0, | ||
| 68 | + y: 0, | ||
| 69 | + width: 1, | ||
| 70 | + height: 1, | ||
| 71 | + rotation: "horizontal", | ||
| 72 | + border: "none", | ||
| 73 | + config: cfg, | ||
| 74 | + } as LabelElement; | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +/** | ||
| 78 | + * 模板中每个 NUTRITION 元素在「录入 / 批量表」中展开的列(表头为营养成分名称)。 | ||
| 79 | + * 仅包含模板里已配置非空展示值的项,与画布预览(LabelCanvas)营养成分表一致。 | ||
| 80 | + */ | ||
| 81 | +export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] { | ||
| 82 | + if (canonicalElementType(el.type) !== "NUTRITION") return []; | ||
| 83 | + const cfg = (el.config ?? {}) as Record<string, unknown>; | ||
| 84 | + const specs: NutritionManualFieldSpec[] = []; | ||
| 85 | + | ||
| 86 | + if (templateCaloriesDisplay(cfg)) { | ||
| 87 | + specs.push({ subKey: "calories", columnLabel: "Calories" }); | ||
| 88 | + } | ||
| 89 | + if (templateServingsPerContainer(cfg)) { | ||
| 90 | + specs.push({ subKey: "servingsPerContainer", columnLabel: "Servings Per Container" }); | ||
| 91 | + } | ||
| 92 | + if (templateServingSize(cfg)) { | ||
| 93 | + specs.push({ subKey: "servingSize", columnLabel: "Serving Size" }); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : []; | ||
| 97 | + const seen = new Set<string>(); | ||
| 98 | + if (fixedArr.length > 0) { | ||
| 99 | + for (const row of fixedArr) { | ||
| 100 | + const key = String(row.key ?? "").trim(); | ||
| 101 | + if (!key || seen.has(key)) continue; | ||
| 102 | + const v = String(row.value ?? "").trim(); | ||
| 103 | + if (!v) continue; | ||
| 104 | + seen.add(key); | ||
| 105 | + const label = String(row.label ?? "").trim() || fixedLabelForKey(key); | ||
| 106 | + specs.push({ subKey: key, columnLabel: label }); | ||
| 107 | + } | ||
| 108 | + } else { | ||
| 109 | + for (const item of NUTRITION_FIXED_ITEMS) { | ||
| 110 | + const v = readFixedValueFromCfg(cfg, item.key).trim(); | ||
| 111 | + if (!v) continue; | ||
| 112 | + specs.push({ subKey: item.key, columnLabel: item.label }); | ||
| 113 | + } | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + for (const ex of nutritionExtraRowsFromCfg(cfg)) { | ||
| 117 | + const id = String(ex.id ?? "").trim(); | ||
| 118 | + if (!id) continue; | ||
| 119 | + const value = String(ex.value ?? "").trim(); | ||
| 120 | + if (!value) continue; | ||
| 121 | + const name = ex.name.trim() || "Other"; | ||
| 122 | + specs.push({ subKey: `extra:${id}:value`, columnLabel: name }); | ||
| 123 | + } | ||
| 124 | + return specs; | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +export function listNutritionElements(elements: LabelElement[]): LabelElement[] { | ||
| 128 | + return (elements ?? []).filter((el) => canonicalElementType(el.type) === "NUTRITION"); | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +/** 从模板 config 初始化手动录入 map */ | ||
| 132 | +export function nutritionManualValuesFromTemplateConfig(el: LabelElement): Record<string, string> { | ||
| 133 | + const cfg = (el.config ?? {}) as Record<string, unknown>; | ||
| 134 | + const out: Record<string, string> = {}; | ||
| 135 | + const specs = listNutritionManualFieldSpecs(el); | ||
| 136 | + for (const s of specs) { | ||
| 137 | + if (s.subKey === "calories") { | ||
| 138 | + out.calories = String(cfg.calories ?? cfg.Calories ?? readFixedValueFromCfg(cfg, "calories") ?? "").trim(); | ||
| 139 | + continue; | ||
| 140 | + } | ||
| 141 | + if (s.subKey === "servingsPerContainer") { | ||
| 142 | + out.servingsPerContainer = String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? "").trim(); | ||
| 143 | + continue; | ||
| 144 | + } | ||
| 145 | + if (s.subKey === "servingSize") { | ||
| 146 | + out.servingSize = String(cfg.servingSize ?? cfg.ServingSize ?? "").trim(); | ||
| 147 | + continue; | ||
| 148 | + } | ||
| 149 | + if (s.subKey.startsWith("extra:") && s.subKey.endsWith(":value")) { | ||
| 150 | + const id = s.subKey.slice("extra:".length, -":value".length); | ||
| 151 | + const ex = nutritionExtraRowsFromCfg(cfg).find((r) => r.id === id); | ||
| 152 | + out[s.subKey] = (ex?.value ?? "").trim(); | ||
| 153 | + continue; | ||
| 154 | + } | ||
| 155 | + out[s.subKey] = readFixedValueFromCfg(cfg, s.subKey); | ||
| 156 | + } | ||
| 157 | + return out; | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +function pickManual( | ||
| 161 | + manual: Record<string, string>, | ||
| 162 | + subKey: string, | ||
| 163 | + baseCfg: Record<string, unknown>, | ||
| 164 | +): string { | ||
| 165 | + const m = String(manual[subKey] ?? "").trim(); | ||
| 166 | + if (m !== "") return m; | ||
| 167 | + if (subKey === "calories") { | ||
| 168 | + return String(baseCfg.calories ?? baseCfg.Calories ?? readFixedValueFromCfg(baseCfg, "calories") ?? "").trim(); | ||
| 169 | + } | ||
| 170 | + if (subKey === "servingsPerContainer") { | ||
| 171 | + return String(baseCfg.servingsPerContainer ?? baseCfg.ServingsPerContainer ?? "").trim(); | ||
| 172 | + } | ||
| 173 | + if (subKey === "servingSize") { | ||
| 174 | + return String(baseCfg.servingSize ?? baseCfg.ServingSize ?? "").trim(); | ||
| 175 | + } | ||
| 176 | + if (subKey.startsWith("extra:") && subKey.endsWith(":value")) { | ||
| 177 | + const id = subKey.slice("extra:".length, -":value".length); | ||
| 178 | + const ex = nutritionExtraRowsFromCfg(baseCfg).find((r) => r.id === id); | ||
| 179 | + return (ex?.value ?? "").trim(); | ||
| 180 | + } | ||
| 181 | + return readFixedValueFromCfg(baseCfg, subKey); | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +/** | ||
| 185 | + * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。 | ||
| 186 | + * 输出中仅保留模板已声明的营养成分行,与 listNutritionManualFieldSpecs 一致。 | ||
| 187 | + */ | ||
| 188 | +export function mergeNutritionManualIntoConfig( | ||
| 189 | + baseCfg: Record<string, unknown>, | ||
| 190 | + manual: Record<string, string>, | ||
| 191 | +): Record<string, unknown> { | ||
| 192 | + const cfg: Record<string, unknown> = { ...baseCfg }; | ||
| 193 | + const specs = listNutritionManualFieldSpecs(fakeNutritionElement(baseCfg)); | ||
| 194 | + const specSubKeys = new Set(specs.map((s) => s.subKey)); | ||
| 195 | + | ||
| 196 | + if (specSubKeys.has("calories")) { | ||
| 197 | + const cal = pickManual(manual, "calories", baseCfg); | ||
| 198 | + if (cal) cfg.calories = cal; | ||
| 199 | + else { | ||
| 200 | + delete cfg.calories; | ||
| 201 | + delete cfg.Calories; | ||
| 202 | + } | ||
| 203 | + } else { | ||
| 204 | + delete cfg.calories; | ||
| 205 | + delete cfg.Calories; | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + if (specSubKeys.has("servingsPerContainer")) { | ||
| 209 | + cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer", baseCfg); | ||
| 210 | + } else { | ||
| 211 | + cfg.servingsPerContainer = ""; | ||
| 212 | + delete cfg.ServingsPerContainer; | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + if (specSubKeys.has("servingSize")) { | ||
| 216 | + cfg.servingSize = pickManual(manual, "servingSize", baseCfg); | ||
| 217 | + } else { | ||
| 218 | + cfg.servingSize = ""; | ||
| 219 | + delete cfg.ServingSize; | ||
| 220 | + } | ||
| 221 | + | ||
| 222 | + const baseFixed = Array.isArray(baseCfg.fixedNutrients) | ||
| 223 | + ? (baseCfg.fixedNutrients as Record<string, unknown>[]) | ||
| 224 | + : []; | ||
| 225 | + const fixedArr: Record<string, unknown>[] = []; | ||
| 226 | + for (const s of specs) { | ||
| 227 | + if (["calories", "servingsPerContainer", "servingSize"].includes(s.subKey)) continue; | ||
| 228 | + if (s.subKey.startsWith("extra:")) continue; | ||
| 229 | + const v = pickManual(manual, s.subKey, baseCfg); | ||
| 230 | + const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === s.subKey); | ||
| 231 | + const unit = String( | ||
| 232 | + baseRow?.unit ?? NUTRITION_FIXED_ITEMS.find((x) => x.key === s.subKey)?.defaultUnit ?? "", | ||
| 233 | + ); | ||
| 234 | + const label = String(baseRow?.label ?? fixedLabelForKey(s.subKey)); | ||
| 235 | + fixedArr.push({ key: s.subKey, label, value: v, unit }); | ||
| 236 | + } | ||
| 237 | + cfg.fixedNutrients = fixedArr; | ||
| 238 | + | ||
| 239 | + const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = []; | ||
| 240 | + for (const s of specs) { | ||
| 241 | + if (!s.subKey.startsWith("extra:") || !s.subKey.endsWith(":value")) continue; | ||
| 242 | + const id = s.subKey.slice("extra:".length, -":value".length); | ||
| 243 | + const base = nutritionExtraRowsFromCfg(baseCfg).find((r) => r.id === id); | ||
| 244 | + newExtras.push({ | ||
| 245 | + id, | ||
| 246 | + name: (base?.name ?? s.columnLabel).trim() || "Other", | ||
| 247 | + value: pickManual(manual, s.subKey, baseCfg), | ||
| 248 | + unit: String(base?.unit ?? "").trim(), | ||
| 249 | + }); | ||
| 250 | + } | ||
| 251 | + cfg.extraNutrients = newExtras; | ||
| 252 | + return cfg; | ||
| 253 | +} | ||
| 254 | + | ||
| 255 | +export function serializeNutritionManualForDefaults(manual: Record<string, string>): string { | ||
| 256 | + const o: Record<string, string> = {}; | ||
| 257 | + for (const [k, v] of Object.entries(manual)) { | ||
| 258 | + const t = String(v ?? "").trim(); | ||
| 259 | + if (t !== "") o[k] = t; | ||
| 260 | + } | ||
| 261 | + return JSON.stringify(o); | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +export function parseNutritionManualFromDefaults(raw: string | undefined): Record<string, string> { | ||
| 265 | + const t = String(raw ?? "").trim(); | ||
| 266 | + if (!t.startsWith("{")) return {}; | ||
| 267 | + try { | ||
| 268 | + const p = JSON.parse(t) as unknown; | ||
| 269 | + if (p == null || typeof p !== "object" || Array.isArray(p)) return {}; | ||
| 270 | + const out: Record<string, string> = {}; | ||
| 271 | + for (const [k, v] of Object.entries(p as Record<string, unknown>)) { | ||
| 272 | + out[k] = String(v ?? ""); | ||
| 273 | + } | ||
| 274 | + return out; | ||
| 275 | + } catch { | ||
| 276 | + return {}; | ||
| 277 | + } | ||
| 278 | +} | ||
| 279 | + | ||
| 280 | +export function nutritionDefaultValuesJsonForSave(manual: Record<string, string>): string | null { | ||
| 281 | + const json = serializeNutritionManualForDefaults(manual); | ||
| 282 | + return json === "{}" ? null : json; | ||
| 283 | +} | ||
| 284 | + | ||
| 285 | +/** 从接口 defaultValues 展开营养成分 JSON 为批量表 composite 列键 */ | ||
| 286 | +export function hydrateRowFieldValuesWithNutritionColumns( | ||
| 287 | + defaultValues: Record<string, string>, | ||
| 288 | + elements: LabelElement[], | ||
| 289 | +): Record<string, string> { | ||
| 290 | + const out = { ...defaultValues }; | ||
| 291 | + for (const nel of listNutritionElements(elements)) { | ||
| 292 | + const raw = out[nel.id]; | ||
| 293 | + if (typeof raw !== "string" || !raw.trim().startsWith("{")) continue; | ||
| 294 | + const parsed = parseNutritionManualFromDefaults(raw); | ||
| 295 | + delete out[nel.id]; | ||
| 296 | + const allowed = new Set(listNutritionManualFieldSpecs(nel).map((s) => s.subKey)); | ||
| 297 | + for (const [sk, val] of Object.entries(parsed)) { | ||
| 298 | + if (!allowed.has(sk)) continue; | ||
| 299 | + out[nutritionCompositeFieldKey(nel.id, sk)] = val; | ||
| 300 | + } | ||
| 301 | + } | ||
| 302 | + return out; | ||
| 303 | +} | ||
| 304 | + | ||
| 305 | +/** 将批量表 composite 列折叠回 defaultValues(元素 id → JSON) */ | ||
| 306 | +export function foldNutritionCompositeKeysIntoDefaults( | ||
| 307 | + fieldValues: Record<string, string>, | ||
| 308 | + elements: LabelElement[], | ||
| 309 | +): Record<string, string> { | ||
| 310 | + const out: Record<string, string> = { ...fieldValues }; | ||
| 311 | + for (const nel of listNutritionElements(elements)) { | ||
| 312 | + const specs = listNutritionManualFieldSpecs(nel); | ||
| 313 | + const manual: Record<string, string> = {}; | ||
| 314 | + for (const s of specs) { | ||
| 315 | + const ck = nutritionCompositeFieldKey(nel.id, s.subKey); | ||
| 316 | + if (Object.prototype.hasOwnProperty.call(fieldValues, ck)) { | ||
| 317 | + manual[s.subKey] = fieldValues[ck] ?? ""; | ||
| 318 | + } | ||
| 319 | + } | ||
| 320 | + const prefix = `${nel.id}${NUTRITION_FIELD_COMPOSITE_SEP}`; | ||
| 321 | + for (const k of Object.keys(out)) { | ||
| 322 | + if (k.startsWith(prefix)) delete out[k]; | ||
| 323 | + } | ||
| 324 | + const j = nutritionDefaultValuesJsonForSave(manual); | ||
| 325 | + if (j) out[nel.id] = j; | ||
| 326 | + else delete out[nel.id]; | ||
| 327 | + } | ||
| 328 | + return out; | ||
| 329 | +} |
美国版/Food Labeling Management Platform/src/main.tsx
| 1 | 1 | ||
| 2 | import { createRoot } from "react-dom/client"; | 2 | import { createRoot } from "react-dom/client"; |
| 3 | import App from "./App.tsx"; | 3 | import App from "./App.tsx"; |
| 4 | + import "react-day-picker/dist/style.css"; | ||
| 4 | import "./index.css"; | 5 | import "./index.css"; |
| 5 | import "./styles/fonts.css"; | 6 | import "./styles/fonts.css"; |
| 6 | 7 |
美国版/Food Labeling Management Platform/src/services/locationService.ts
| 1 | import { createApiClient } from "../lib/apiClient"; | 1 | import { createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; | ||
| 2 | import type { | 3 | import type { |
| 3 | LocationCreateInput, | 4 | LocationCreateInput, |
| 4 | LocationDto, | 5 | LocationDto, |
| @@ -106,3 +107,89 @@ export async function deleteLocation(id: string): Promise<void> { | @@ -106,3 +107,89 @@ export async function deleteLocation(id: string): Promise<void> { | ||
| 106 | }); | 107 | }); |
| 107 | } | 108 | } |
| 108 | 109 | ||
| 110 | +/** 与列表筛选一致,用于模板下载外的 Excel 全量导出(不含分页)。 */ | ||
| 111 | +export type LocationExportQueryInput = { | ||
| 112 | + sorting?: string; | ||
| 113 | + keyword?: string; | ||
| 114 | + partner?: string; | ||
| 115 | + groupName?: string; | ||
| 116 | + state?: boolean; | ||
| 117 | +}; | ||
| 118 | + | ||
| 119 | +export type LocationBatchImportResultDto = { | ||
| 120 | + successCount: number; | ||
| 121 | + failCount: number; | ||
| 122 | + skippedEmptyRows?: number; | ||
| 123 | + errors?: Array<{ | ||
| 124 | + rowNumber?: number; | ||
| 125 | + locationCode?: string; | ||
| 126 | + message?: string; | ||
| 127 | + }>; | ||
| 128 | +}; | ||
| 129 | + | ||
| 130 | +export type LocationBulkUpdateItemVo = { | ||
| 131 | + id: string; | ||
| 132 | + partner?: string | null; | ||
| 133 | + groupName?: string | null; | ||
| 134 | + locationName: string; | ||
| 135 | + street?: string | null; | ||
| 136 | + city?: string | null; | ||
| 137 | + stateCode?: string | null; | ||
| 138 | + country?: string | null; | ||
| 139 | + zipCode?: string | null; | ||
| 140 | + phone?: string | null; | ||
| 141 | + email?: string | null; | ||
| 142 | + latitude?: number | null; | ||
| 143 | + longitude?: number | null; | ||
| 144 | + state?: boolean; | ||
| 145 | +}; | ||
| 146 | + | ||
| 147 | +export type LocationBulkUpdateResultDto = { | ||
| 148 | + successCount: number; | ||
| 149 | + failCount: number; | ||
| 150 | + errors?: Array<{ rowNumber?: number; id?: string; message?: string }>; | ||
| 151 | +}; | ||
| 152 | + | ||
| 153 | +export async function downloadLocationImportTemplate(signal?: AbortSignal): Promise<void> { | ||
| 154 | + await authorizedGetBlobDownload({ | ||
| 155 | + path: "/location/download-location-import-template", | ||
| 156 | + defaultFileName: "Location-Manager-template.xlsx", | ||
| 157 | + signal, | ||
| 158 | + }); | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +export async function exportLocationsExcel(input: LocationExportQueryInput, signal?: AbortSignal): Promise<void> { | ||
| 162 | + await authorizedGetBlobDownload({ | ||
| 163 | + path: "/location/export-locations-excel", | ||
| 164 | + query: { | ||
| 165 | + Sorting: input.sorting, | ||
| 166 | + Keyword: input.keyword, | ||
| 167 | + Partner: input.partner, | ||
| 168 | + GroupName: input.groupName, | ||
| 169 | + State: input.state, | ||
| 170 | + }, | ||
| 171 | + defaultFileName: "locations-export.xlsx", | ||
| 172 | + signal, | ||
| 173 | + }); | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +export async function importLocationsBatch(file: File, signal?: AbortSignal): Promise<LocationBatchImportResultDto> { | ||
| 177 | + return authorizedPostMultipartJson<LocationBatchImportResultDto>({ | ||
| 178 | + path: "/location/import-locations-batch", | ||
| 179 | + fieldName: "file", | ||
| 180 | + file, | ||
| 181 | + signal, | ||
| 182 | + }); | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +export async function updateLocationsBulk( | ||
| 186 | + body: { items: LocationBulkUpdateItemVo[] }, | ||
| 187 | +): Promise<LocationBulkUpdateResultDto> { | ||
| 188 | + // ABP 约定控制器:`Update*Async` 多为 PUT,POST 会 405 | ||
| 189 | + return api.requestJson<LocationBulkUpdateResultDto>({ | ||
| 190 | + path: "/location/update-locations-bulk", | ||
| 191 | + method: "PUT", | ||
| 192 | + body, | ||
| 193 | + }); | ||
| 194 | +} | ||
| 195 | + |
美国版/Food Labeling Management Platform/src/services/productService.ts
| 1 | import { createApiClient } from "../lib/apiClient"; | 1 | import { createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; | ||
| 2 | import type { | 3 | import type { |
| 3 | ProductCreateInput, | 4 | ProductCreateInput, |
| 4 | ProductDto, | 5 | ProductDto, |
| @@ -116,3 +117,70 @@ export async function deleteProduct(id: string): Promise<void> { | @@ -116,3 +117,70 @@ export async function deleteProduct(id: string): Promise<void> { | ||
| 116 | method: "DELETE", | 117 | method: "DELETE", |
| 117 | }); | 118 | }); |
| 118 | } | 119 | } |
| 120 | + | ||
| 121 | +export type ProductExportQueryInput = { | ||
| 122 | + keyword?: string; | ||
| 123 | + state?: boolean; | ||
| 124 | + sorting?: string; | ||
| 125 | +}; | ||
| 126 | + | ||
| 127 | +export type ProductBatchImportResultDto = { | ||
| 128 | + successCount: number; | ||
| 129 | + failCount: number; | ||
| 130 | + errors?: Array<{ rowNumber?: number; productName?: string; message?: string }>; | ||
| 131 | +}; | ||
| 132 | + | ||
| 133 | +export type ProductBulkUpdateItemVo = { | ||
| 134 | + id: string; | ||
| 135 | + productCode?: string | null; | ||
| 136 | + productName: string; | ||
| 137 | + categoryId?: string | null; | ||
| 138 | + productImageUrl?: string | null; | ||
| 139 | + state?: boolean; | ||
| 140 | + locationIds?: string[]; | ||
| 141 | +}; | ||
| 142 | + | ||
| 143 | +export type ProductBulkUpdateResultDto = { | ||
| 144 | + successCount: number; | ||
| 145 | + failCount: number; | ||
| 146 | + errors?: Array<{ rowNumber?: number; id?: string; message?: string }>; | ||
| 147 | +}; | ||
| 148 | + | ||
| 149 | +export async function downloadProductImportTemplate(signal?: AbortSignal): Promise<void> { | ||
| 150 | + await authorizedGetBlobDownload({ | ||
| 151 | + path: `${PATH}/download-product-import-template`, | ||
| 152 | + defaultFileName: "Product-Manager-template.xlsx", | ||
| 153 | + signal, | ||
| 154 | + }); | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +export async function exportProductsExcel(input: ProductExportQueryInput, signal?: AbortSignal): Promise<void> { | ||
| 158 | + await authorizedGetBlobDownload({ | ||
| 159 | + path: `${PATH}/export-products-excel`, | ||
| 160 | + query: { | ||
| 161 | + Keyword: input.keyword, | ||
| 162 | + State: input.state, | ||
| 163 | + Sorting: input.sorting, | ||
| 164 | + }, | ||
| 165 | + defaultFileName: "products-export.xlsx", | ||
| 166 | + signal, | ||
| 167 | + }); | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +export async function importProductsBatch(file: File, signal?: AbortSignal): Promise<ProductBatchImportResultDto> { | ||
| 171 | + return authorizedPostMultipartJson<ProductBatchImportResultDto>({ | ||
| 172 | + path: `${PATH}/import-products-batch`, | ||
| 173 | + fieldName: "file", | ||
| 174 | + file, | ||
| 175 | + signal, | ||
| 176 | + }); | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +export async function updateProductsBulk(body: { items: ProductBulkUpdateItemVo[] }): Promise<ProductBulkUpdateResultDto> { | ||
| 180 | + // ABP 约定控制器:`Update*Async` 多为 PUT,POST 会 405 | ||
| 181 | + return api.requestJson<ProductBulkUpdateResultDto>({ | ||
| 182 | + path: `${PATH}/update-products-bulk`, | ||
| 183 | + method: "PUT", | ||
| 184 | + body, | ||
| 185 | + }); | ||
| 186 | +} |
美国版/Food Labeling Management Platform/src/services/reportsService.ts
| 1 | import { ApiError, createApiClient } from "../lib/apiClient"; | 1 | import { ApiError, createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload } from "../lib/batchFileHttp"; | ||
| 2 | import type { | 3 | import type { |
| 3 | LabelReportData, | 4 | LabelReportData, |
| 4 | LabelReportQueryInput, | 5 | LabelReportQueryInput, |
| @@ -385,6 +386,23 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi | @@ -385,6 +386,23 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi | ||
| 385 | URL.revokeObjectURL(url); | 386 | URL.revokeObjectURL(url); |
| 386 | } | 387 | } |
| 387 | 388 | ||
| 389 | +export async function exportPrintLogExcel(input: ReportsPrintLogQueryInput, signal?: AbortSignal): Promise<void> { | ||
| 390 | + await authorizedGetBlobDownload({ | ||
| 391 | + path: `${REPORTS_PREFIX}/export-print-log-excel`, | ||
| 392 | + query: { | ||
| 393 | + Sorting: input.sorting ?? "PrintedAt desc", | ||
| 394 | + PartnerId: input.partnerId, | ||
| 395 | + GroupId: input.groupId, | ||
| 396 | + LocationId: input.locationId, | ||
| 397 | + StartDate: input.startDate, | ||
| 398 | + EndDate: input.endDate, | ||
| 399 | + Keyword: input.keyword, | ||
| 400 | + }, | ||
| 401 | + defaultFileName: "print-log-export.xlsx", | ||
| 402 | + signal, | ||
| 403 | + }); | ||
| 404 | +} | ||
| 405 | + | ||
| 388 | export async function exportLabelReportPdf(input: LabelReportQueryInput): Promise<void> { | 406 | export async function exportLabelReportPdf(input: LabelReportQueryInput): Promise<void> { |
| 389 | const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; | 407 | const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; |
| 390 | const path = joinUrl( | 408 | const path = joinUrl( |
美国版/Food Labeling Management Platform/src/services/roleService.ts
| 1 | -import { createApiClient } from "../lib/apiClient"; | 1 | +import { ApiError, createApiClient } from "../lib/apiClient"; |
| 2 | import type { PagedResultDto, RoleDto, RoleGetListInput } from "../types/role"; | 2 | import type { PagedResultDto, RoleDto, RoleGetListInput } from "../types/role"; |
| 3 | 3 | ||
| 4 | const api = createApiClient({ | 4 | const api = createApiClient({ |
| @@ -29,3 +29,74 @@ export async function getRoles(input: RoleGetListInput, signal?: AbortSignal): P | @@ -29,3 +29,74 @@ export async function getRoles(input: RoleGetListInput, signal?: AbortSignal): P | ||
| 29 | }); | 29 | }); |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | +function getBaseUrl(): string { | ||
| 33 | + return import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +function getToken(): string | null { | ||
| 37 | + try { | ||
| 38 | + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null; | ||
| 39 | + } catch { | ||
| 40 | + return null; | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +function joinUrl(baseUrl: string, path: string): string { | ||
| 45 | + const b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; | ||
| 46 | + const p = path.startsWith("/") ? path : `/${path}`; | ||
| 47 | + return `${b}${p}`; | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +function toQueryString(params: Record<string, unknown>): string { | ||
| 51 | + const qs = new URLSearchParams(); | ||
| 52 | + for (const [k, v] of Object.entries(params)) { | ||
| 53 | + if (v === undefined || v === null || v === "") continue; | ||
| 54 | + if (typeof v === "boolean") { | ||
| 55 | + qs.set(k, v ? "true" : "false"); | ||
| 56 | + continue; | ||
| 57 | + } | ||
| 58 | + qs.set(k, String(v)); | ||
| 59 | + } | ||
| 60 | + const s = qs.toString(); | ||
| 61 | + return s ? `?${s}` : ""; | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +export type RoleExportQuery = { | ||
| 65 | + roleName?: string; | ||
| 66 | + roleCode?: string; | ||
| 67 | + state?: boolean; | ||
| 68 | +}; | ||
| 69 | + | ||
| 70 | +/** GET /api/app/role/export-pdf — 与角色列表筛选字段一致 */ | ||
| 71 | +export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSignal): Promise<Blob> { | ||
| 72 | + const baseUrl = getBaseUrl(); | ||
| 73 | + const token = getToken(); | ||
| 74 | + const url = joinUrl( | ||
| 75 | + baseUrl, | ||
| 76 | + `/api/app/role/export-pdf${toQueryString({ | ||
| 77 | + RoleName: input.roleName, | ||
| 78 | + RoleCode: input.roleCode, | ||
| 79 | + State: input.state, | ||
| 80 | + })}`, | ||
| 81 | + ); | ||
| 82 | + const headers: Record<string, string> = {}; | ||
| 83 | + if (token) headers.Authorization = `Bearer ${token}`; | ||
| 84 | + const res = await fetch(url, { method: "GET", headers, signal }); | ||
| 85 | + if (!res.ok) { | ||
| 86 | + const ct = res.headers.get("content-type") ?? ""; | ||
| 87 | + let msg = "Export failed."; | ||
| 88 | + if (ct.includes("application/json")) { | ||
| 89 | + const payload = await res.json().catch(() => null); | ||
| 90 | + const m = | ||
| 91 | + (payload as { error?: { message?: string } })?.error?.message?.trim() || | ||
| 92 | + (payload as { message?: string })?.message?.trim(); | ||
| 93 | + if (m) msg = m; | ||
| 94 | + } else { | ||
| 95 | + const t = await res.text().catch(() => ""); | ||
| 96 | + if (t.trim()) msg = t.trim(); | ||
| 97 | + } | ||
| 98 | + throw new ApiError(msg, res.status, null); | ||
| 99 | + } | ||
| 100 | + return res.blob(); | ||
| 101 | +} | ||
| 102 | + |
美国版/Food Labeling Management Platform/src/services/teamMemberService.ts
| 1 | import { createApiClient } from "../lib/apiClient"; | 1 | import { createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; | ||
| 2 | import type { | 3 | import type { |
| 3 | PagedResultDto, | 4 | PagedResultDto, |
| 4 | TeamMemberCreateInput, | 5 | TeamMemberCreateInput, |
| @@ -152,6 +153,10 @@ export async function getTeamMembers( | @@ -152,6 +153,10 @@ export async function getTeamMembers( | ||
| 152 | SkipCount: input.skipCount, | 153 | SkipCount: input.skipCount, |
| 153 | MaxResultCount: input.maxResultCount, | 154 | MaxResultCount: input.maxResultCount, |
| 154 | Keyword: input.keyword, | 155 | Keyword: input.keyword, |
| 156 | + RoleId: input.roleId, | ||
| 157 | + LocationId: input.locationId, | ||
| 158 | + State: input.state, | ||
| 159 | + Sorting: input.sorting, | ||
| 155 | }, | 160 | }, |
| 156 | signal, | 161 | signal, |
| 157 | }); | 162 | }); |
| @@ -238,3 +243,79 @@ export async function deleteTeamMember(id: string): Promise<void> { | @@ -238,3 +243,79 @@ export async function deleteTeamMember(id: string): Promise<void> { | ||
| 238 | }); | 243 | }); |
| 239 | } | 244 | } |
| 240 | 245 | ||
| 246 | +/** PDF 全量导出筛选(与列表一致,不含分页)。 */ | ||
| 247 | +export type TeamMemberExportQueryInput = { | ||
| 248 | + keyword?: string; | ||
| 249 | + roleId?: string; | ||
| 250 | + locationId?: string; | ||
| 251 | + state?: boolean; | ||
| 252 | + sorting?: string; | ||
| 253 | +}; | ||
| 254 | + | ||
| 255 | +export type TeamMemberBatchImportResultDto = { | ||
| 256 | + successCount: number; | ||
| 257 | + failCount: number; | ||
| 258 | + errors?: Array<{ rowNumber?: number; userName?: string; message?: string }>; | ||
| 259 | +}; | ||
| 260 | + | ||
| 261 | +export type TeamMemberBulkUpdateItemVo = { | ||
| 262 | + id: string; | ||
| 263 | + fullName: string; | ||
| 264 | + userName: string; | ||
| 265 | + password?: string | null; | ||
| 266 | + email?: string | null; | ||
| 267 | + phone?: number | null; | ||
| 268 | + roleId: string; | ||
| 269 | + locationIds: string[]; | ||
| 270 | + state: boolean; | ||
| 271 | +}; | ||
| 272 | + | ||
| 273 | +export type TeamMemberBulkUpdateResultDto = { | ||
| 274 | + successCount: number; | ||
| 275 | + failCount: number; | ||
| 276 | + errors?: Array<{ rowNumber?: number; id?: string; message?: string }>; | ||
| 277 | +}; | ||
| 278 | + | ||
| 279 | +export async function downloadTeamMemberImportTemplate(signal?: AbortSignal): Promise<void> { | ||
| 280 | + await authorizedGetBlobDownload({ | ||
| 281 | + path: `${PATH}/download-team-member-import-template`, | ||
| 282 | + defaultFileName: "Team-Member-template.xlsx", | ||
| 283 | + signal, | ||
| 284 | + }); | ||
| 285 | +} | ||
| 286 | + | ||
| 287 | +export async function exportTeamMembersPdf(input: TeamMemberExportQueryInput, signal?: AbortSignal): Promise<void> { | ||
| 288 | + await authorizedGetBlobDownload({ | ||
| 289 | + path: `${PATH}/export-team-members-pdf`, | ||
| 290 | + query: { | ||
| 291 | + Keyword: input.keyword, | ||
| 292 | + RoleId: input.roleId, | ||
| 293 | + LocationId: input.locationId, | ||
| 294 | + State: input.state, | ||
| 295 | + Sorting: input.sorting, | ||
| 296 | + }, | ||
| 297 | + defaultFileName: "team-members.pdf", | ||
| 298 | + signal, | ||
| 299 | + }); | ||
| 300 | +} | ||
| 301 | + | ||
| 302 | +export async function importTeamMembersBatch(file: File, signal?: AbortSignal): Promise<TeamMemberBatchImportResultDto> { | ||
| 303 | + return authorizedPostMultipartJson<TeamMemberBatchImportResultDto>({ | ||
| 304 | + path: `${PATH}/import-team-members-batch`, | ||
| 305 | + fieldName: "file", | ||
| 306 | + file, | ||
| 307 | + signal, | ||
| 308 | + }); | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +export async function updateTeamMembersBulk( | ||
| 312 | + body: { items: TeamMemberBulkUpdateItemVo[] }, | ||
| 313 | +): Promise<TeamMemberBulkUpdateResultDto> { | ||
| 314 | + // ABP 约定控制器:`Update*Async` 多为 PUT,POST 会 405 | ||
| 315 | + return api.requestJson<TeamMemberBulkUpdateResultDto>({ | ||
| 316 | + path: `${PATH}/update-team-members-bulk`, | ||
| 317 | + method: "PUT", | ||
| 318 | + body, | ||
| 319 | + }); | ||
| 320 | +} | ||
| 321 | + |
美国版/Food Labeling Management Platform/src/types/labelTemplate.ts
| @@ -600,19 +600,22 @@ export function isBlankSpaceElement(el: LabelElement): boolean { | @@ -600,19 +600,22 @@ export function isBlankSpaceElement(el: LabelElement): boolean { | ||
| 600 | } | 600 | } |
| 601 | 601 | ||
| 602 | /** | 602 | /** |
| 603 | - * 录入数据表格:仅展示「元素面板红框」对应的 typeAdd(Template / Label 分组), | ||
| 604 | - * 且必须满足 valueSourceType === FIXED 才可录入。 | ||
| 605 | - * Print input、Auto-generated、未在红框内的控件(如 Nutrition Facts、Blank Space)不进入表格。 | 603 | + * 左侧 **Template** 面板拖入的元素(持久化 typeAdd / type 以 `template_` 开头)。 |
| 604 | + * 其文案/图片等在模板编辑器内固化,不参与「按产品绑定默认值」表与新建标签的中间录入列。 | ||
| 605 | + */ | ||
| 606 | +export function isTemplateSectionPersistedType(el: LabelElement): boolean { | ||
| 607 | + return resolvedTypeAddForPersist(el).trim().toLowerCase().startsWith("template_"); | ||
| 608 | +} | ||
| 609 | + | ||
| 610 | +/** | ||
| 611 | + * 录入数据表格:仅展示 **Label** 分组等需在「产品×标签类型」行上维护默认值的列; | ||
| 612 | + * **Template** 分组控件在编辑器内保存,此处不展示。 | ||
| 613 | + * Print input、Auto-generated、Nutrition(拆列)、Blank Space 等按原规则处理。 | ||
| 606 | */ | 614 | */ |
| 607 | export function isDataEntryTableColumnElement(el: LabelElement): boolean { | 615 | export function isDataEntryTableColumnElement(el: LabelElement): boolean { |
| 608 | const persistedType = resolvedTypeAddForPersist(el).trim().toLowerCase(); | 616 | const persistedType = resolvedTypeAddForPersist(el).trim().toLowerCase(); |
| 617 | + if (isTemplateSectionPersistedType(el)) return false; | ||
| 609 | const manualTypeAddWhitelist = new Set([ | 618 | const manualTypeAddWhitelist = new Set([ |
| 610 | - "template_text", | ||
| 611 | - "template_qr code", | ||
| 612 | - "template_barcode", | ||
| 613 | - "template_price", | ||
| 614 | - "template_logo", | ||
| 615 | - "template_image", | ||
| 616 | "label_label name", | 619 | "label_label name", |
| 617 | "label_text", | 620 | "label_text", |
| 618 | "label_qr code", | 621 | "label_qr code", |
| @@ -626,7 +629,6 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean { | @@ -626,7 +629,6 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean { | ||
| 626 | "label_how-to", | 629 | "label_how-to", |
| 627 | "label_expiration alert", | 630 | "label_expiration alert", |
| 628 | ]); | 631 | ]); |
| 629 | - const type = canonicalElementType(el.type); | ||
| 630 | const vst = normalizeValueSourceTypeForElement(el); | 632 | const vst = normalizeValueSourceTypeForElement(el); |
| 631 | if (isBlankSpaceElement(el)) return false; | 633 | if (isBlankSpaceElement(el)) return false; |
| 632 | if (!manualTypeAddWhitelist.has(persistedType)) return false; | 634 | if (!manualTypeAddWhitelist.has(persistedType)) return false; |
美国版/Food Labeling Management Platform/src/types/teamMember.ts
| @@ -27,6 +27,10 @@ export type TeamMemberGetListInput = { | @@ -27,6 +27,10 @@ export type TeamMemberGetListInput = { | ||
| 27 | skipCount: number; // pageIndex (1-based) | 27 | skipCount: number; // pageIndex (1-based) |
| 28 | maxResultCount: number; // pageSize | 28 | maxResultCount: number; // pageSize |
| 29 | keyword?: string; | 29 | keyword?: string; |
| 30 | + roleId?: string; | ||
| 31 | + locationId?: string; | ||
| 32 | + state?: boolean; | ||
| 33 | + sorting?: string; | ||
| 30 | }; | 34 | }; |
| 31 | 35 | ||
| 32 | export type TeamMemberCreateInput = { | 36 | export type TeamMemberCreateInput = { |