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 | 111 | { key: 'home', path: '/pages/index/index', icon: 'home', labelKey: 'Home' }, |
| 112 | 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 | 114 | key: 'report', |
| 124 | 115 | icon: 'fileText', |
| 125 | 116 | labelKey: 'more.report', |
| ... | ... | @@ -147,12 +138,6 @@ watch( |
| 147 | 138 | if (currentPath.value.startsWith('/pages/more/print-log') || currentPath.value.startsWith('/pages/more/label-report')) { |
| 148 | 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 | 141 | nextTick(() => { |
| 157 | 142 | animClass.value = 'opening' |
| 158 | 143 | }) | ... | ... |
美国版/Food Labeling Management App UniApp/src/locales/en.ts
| 1 | 1 | export default { |
| 2 | 2 | Home: 'Home', |
| 3 | 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 | 4 | common: { back: 'Back', confirm: 'Confirm', cancel: 'Cancel', online: 'Online', loading: 'Loading…' }, |
| 19 | 5 | login: { |
| 20 | 6 | appName: 'Food Label System', | ... | ... |
美国版/Food Labeling Management App UniApp/src/locales/zh.ts
| 1 | 1 | export default { |
| 2 | 2 | Home: '首页', |
| 3 | 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 | 4 | common: { back: '返回', confirm: '确认', cancel: '取消', online: '在线', loading: '加载中…' }, |
| 19 | 5 | login: { |
| 20 | 6 | appName: '食品标签系统', | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages.json
| ... | ... | @@ -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 | 32 | "path": "pages/labels/food-select", |
| 47 | 33 | "style": { |
| 48 | 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 | 51 | </view> |
| 52 | 52 | |
| 53 | 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 | 62 | <view |
| 63 | + v-else | |
| 55 | 64 | class="confirm-btn" |
| 56 | 65 | :class="{ disabled: !selectedStore || loading }" |
| 57 | 66 | @click="handleConfirm" |
| ... | ... | @@ -71,7 +80,7 @@ import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' |
| 71 | 80 | import { usAppFetchMyLocations } from '../../services/usAppAuth' |
| 72 | 81 | import type { UsAppBoundLocationDto } from '../../types/usAppBound' |
| 73 | 82 | import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' |
| 74 | -import { setBoundLocations, getBoundLocations } from '../../utils/authSession' | |
| 83 | +import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession' | |
| 75 | 84 | import { switchStore } from '../../utils/stores' |
| 76 | 85 | |
| 77 | 86 | const { t } = useI18n() |
| ... | ... | @@ -111,6 +120,11 @@ onShow(() => { |
| 111 | 120 | applyList(getBoundLocations()) |
| 112 | 121 | }) |
| 113 | 122 | |
| 123 | +const handleBackToLogin = () => { | |
| 124 | + clearAuthSession() | |
| 125 | + uni.redirectTo({ url: '/pages/login/login' }) | |
| 126 | +} | |
| 127 | + | |
| 114 | 128 | const handleConfirm = () => { |
| 115 | 129 | if (loading.value || !selectedStore.value) { |
| 116 | 130 | if (!selectedStore.value) { |
| ... | ... | @@ -277,6 +291,35 @@ const handleConfirm = () => { |
| 277 | 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 | 323 | .confirm-btn { |
| 281 | 324 | width: 100%; |
| 282 | 325 | height: 96rpx; | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
| 1 | 1 | import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' |
| 2 | 2 | import { resolveTemplateDefaultValueForElement } from './printInputOffset' |
| 3 | +import { applyNutritionDefaultJsonToConfig } from './nutritionDefaultsMerge' | |
| 3 | 4 | |
| 4 | 5 | function asRecord(v: unknown): Record<string, unknown> { |
| 5 | 6 | if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown> |
| ... | ... | @@ -285,6 +286,17 @@ export function applyTemplateProductDefaultValuesToTemplate( |
| 285 | 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 | 300 | cfg.text = v |
| 289 | 301 | cfg.Text = v |
| 290 | 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
美国版/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 | 23 | /// <param name="roleId">角色ID</param> |
| 24 | 24 | /// <returns>角色部门树数据,包含已选中的部门ID和部门树结构</returns> |
| 25 | 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 | 1 | using Mapster; |
| 2 | 2 | using Microsoft.AspNetCore.Mvc; |
| 3 | +using QuestPDF.Fluent; | |
| 4 | +using QuestPDF.Helpers; | |
| 5 | +using QuestPDF.Infrastructure; | |
| 3 | 6 | using SqlSugar; |
| 7 | +using System.IO; | |
| 8 | +using Volo.Abp; | |
| 4 | 9 | using Volo.Abp.Application.Dtos; |
| 5 | 10 | using Volo.Abp.Application.Services; |
| 6 | 11 | using Volo.Abp.Domain.Entities; |
| ... | ... | @@ -73,6 +78,77 @@ namespace Yi.Framework.Rbac.Application.Services.System |
| 73 | 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 | 152 | /// <summary> |
| 77 | 153 | /// 添加角色 |
| 78 | 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 | 9 | |
| 10 | 10 | <ItemGroup> |
| 11 | 11 | <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" /> |
| 12 | + <PackageReference Include="QuestPDF" Version="2024.12.2" /> | |
| 12 | 13 | <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" /> |
| 13 | 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 | 5 | <meta charset="UTF-8" /> |
| 6 | 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 7 | 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 | 10 | </head> |
| 11 | 11 | |
| 12 | 12 | <body> | ... | ... |
美国版/Food Labeling Management Platform/src/App.tsx
| ... | ... | @@ -33,6 +33,8 @@ function AuthedApp() { |
| 33 | 33 | /** Dashboard「View Reports」:与 reportsTargetTab 配合,仅在意图跳转时递增 */ |
| 34 | 34 | const [reportsOpenKey, setReportsOpenKey] = useState(0); |
| 35 | 35 | const [reportsTargetTab, setReportsTargetTab] = useState<'print-log' | 'label-report'>('print-log'); |
| 36 | + /** 标签模板编辑器全屏:Layout 隐藏侧栏/顶栏 */ | |
| 37 | + const [labelTemplateEditorFullscreen, setLabelTemplateEditorFullscreen] = useState(false); | |
| 36 | 38 | |
| 37 | 39 | const resolveView = (name: string) => { |
| 38 | 40 | const s = (name ?? "").trim(); |
| ... | ... | @@ -67,6 +69,9 @@ function AuthedApp() { |
| 67 | 69 | if (resolvedView !== 'Reports') { |
| 68 | 70 | setReportsOpenKey(0); |
| 69 | 71 | } |
| 72 | + if (resolvedView !== 'Label Templates') { | |
| 73 | + setLabelTemplateEditorFullscreen(false); | |
| 74 | + } | |
| 70 | 75 | }, [resolvedView]); |
| 71 | 76 | |
| 72 | 77 | if (!auth.token) { |
| ... | ... | @@ -128,6 +133,7 @@ function AuthedApp() { |
| 128 | 133 | onViewChange={setCurrentView} |
| 129 | 134 | labelCreateOpenSeq={labelCreateOpenSeq} |
| 130 | 135 | onLabelCreateIntentConsumed={consumeLabelCreateIntent} |
| 136 | + onLabelTemplateEditorLayoutOverlay={setLabelTemplateEditorFullscreen} | |
| 131 | 137 | /> |
| 132 | 138 | ); |
| 133 | 139 | default: |
| ... | ... | @@ -137,7 +143,13 @@ function AuthedApp() { |
| 137 | 143 | |
| 138 | 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 | 153 | {renderView()} |
| 142 | 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 | 40 | serializePrintInputOffset, |
| 41 | 41 | tryParsePrintInputOffsetStored, |
| 42 | 42 | } from '../../lib/labelFormDatePreview'; |
| 43 | +import { | |
| 44 | + foldNutritionCompositeKeysIntoDefaults, | |
| 45 | + hydrateRowFieldValuesWithNutritionColumns, | |
| 46 | + listNutritionManualFieldSpecs, | |
| 47 | + nutritionCompositeFieldKey, | |
| 48 | + type NutritionManualFieldSpec, | |
| 49 | +} from '../../lib/nutritionManualEntry'; | |
| 43 | 50 | import type { ProductDto } from '../../types/product'; |
| 44 | 51 | import type { LabelTypeDto } from '../../types/labelType'; |
| 45 | 52 | |
| ... | ... | @@ -168,11 +175,31 @@ export function LabelTemplateDataEntryView({ |
| 168 | 175 | const [templateTitle, setTemplateTitle] = useState(''); |
| 169 | 176 | /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */ |
| 170 | 177 | const [templateDto, setTemplateDto] = useState<LabelTemplateDto | null>(null); |
| 171 | - const [printFields, setPrintFields] = useState<LabelElement[]>([]); | |
| 172 | 178 | const [products, setProducts] = useState<ProductDto[]>([]); |
| 173 | 179 | const [types, setTypes] = useState<LabelTypeDto[]>([]); |
| 174 | 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 | 203 | const productOptions = useMemo( |
| 177 | 204 | () => |
| 178 | 205 | products.map((p) => { |
| ... | ... | @@ -209,11 +236,6 @@ export function LabelTemplateDataEntryView({ |
| 209 | 236 | (tpl.templateCode ?? tpl.id ?? '').trim() || |
| 210 | 237 | templateCode; |
| 211 | 238 | setTemplateTitle(title); |
| 212 | - const elements = sortTemplateElementsForDisplay( | |
| 213 | - (tpl.elements ?? []) as LabelElement[], | |
| 214 | - ) | |
| 215 | - .filter(isDataEntryTableColumnElement); | |
| 216 | - setPrintFields(elements); | |
| 217 | 239 | setProducts(prodRes.items ?? []); |
| 218 | 240 | setTypes(typeRes.items ?? []); |
| 219 | 241 | setTemplateDto(tpl); |
| ... | ... | @@ -230,7 +252,10 @@ export function LabelTemplateDataEntryView({ |
| 230 | 252 | id: newRowId(), |
| 231 | 253 | productId: d.productId, |
| 232 | 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 | 261 | } else { |
| ... | ... | @@ -249,7 +274,6 @@ export function LabelTemplateDataEntryView({ |
| 249 | 274 | description: e instanceof Error ? e.message : 'Please try again.', |
| 250 | 275 | }); |
| 251 | 276 | setTemplateTitle(templateCode); |
| 252 | - setPrintFields([]); | |
| 253 | 277 | setRows([]); |
| 254 | 278 | setTemplateDto(null); |
| 255 | 279 | } |
| ... | ... | @@ -312,10 +336,22 @@ export function LabelTemplateDataEntryView({ |
| 312 | 336 | } |
| 313 | 337 | |
| 314 | 338 | const validRows = rows.filter((r) => r.productId.trim() && r.labelTypeId.trim()); |
| 339 | + const fullElements = sortTemplateElementsForDisplay( | |
| 340 | + (templateDto.elements ?? []) as LabelElement[], | |
| 341 | + ); | |
| 315 | 342 | const templateProductDefaults = validRows.map((r, i) => { |
| 343 | + const folded = foldNutritionCompositeKeysIntoDefaults(r.fieldValues, fullElements); | |
| 316 | 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 | 356 | return { |
| 321 | 357 | productId: r.productId.trim(), |
| ... | ... | @@ -325,9 +361,6 @@ export function LabelTemplateDataEntryView({ |
| 325 | 361 | }; |
| 326 | 362 | }); |
| 327 | 363 | |
| 328 | - const fullElements = sortTemplateElementsForDisplay( | |
| 329 | - (templateDto.elements ?? []) as LabelElement[], | |
| 330 | - ); | |
| 331 | 364 | if (fullElements.length === 0) { |
| 332 | 365 | toast.error('Template has no elements', { description: 'Cannot save this template.' }); |
| 333 | 366 | return; |
| ... | ... | @@ -362,7 +395,7 @@ export function LabelTemplateDataEntryView({ |
| 362 | 395 | } finally { |
| 363 | 396 | setSaving(false); |
| 364 | 397 | } |
| 365 | - }, [templateCode, templateDto, rows, printFields]); | |
| 398 | + }, [templateCode, templateDto, rows, dataColumns]); | |
| 366 | 399 | |
| 367 | 400 | return ( |
| 368 | 401 | <div className="h-full flex flex-col min-h-0"> |
| ... | ... | @@ -407,23 +440,21 @@ export function LabelTemplateDataEntryView({ |
| 407 | 440 | |
| 408 | 441 | <p className="text-sm text-gray-600 py-3 shrink-0"> |
| 409 | 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 | 450 | </p> |
| 420 | 451 | |
| 421 | 452 | <div className="flex-1 min-h-0 overflow-auto rounded-md border bg-white shadow-sm"> |
| 422 | 453 | {loading ? ( |
| 423 | 454 | <div className="p-10 text-center text-sm text-gray-500">Loading…</div> |
| 424 | - ) : printFields.length === 0 ? ( | |
| 455 | + ) : dataColumns.length === 0 ? ( | |
| 425 | 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 | 458 | </div> |
| 428 | 459 | ) : ( |
| 429 | 460 | <Table> |
| ... | ... | @@ -435,13 +466,17 @@ export function LabelTemplateDataEntryView({ |
| 435 | 466 | <TableHead className="font-bold text-gray-900 w-[180px] min-w-[140px]"> |
| 436 | 467 | Label type |
| 437 | 468 | </TableHead> |
| 438 | - {printFields.map((f) => ( | |
| 469 | + {dataColumns.map((col) => ( | |
| 439 | 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 | 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 | 480 | </TableHead> |
| 446 | 481 | ))} |
| 447 | 482 | <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead> |
| ... | ... | @@ -468,13 +503,39 @@ export function LabelTemplateDataEntryView({ |
| 468 | 503 | searchPlaceholder="Search type…" |
| 469 | 504 | /> |
| 470 | 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 | 539 | </TableCell> |
| 479 | 540 | ))} |
| 480 | 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 | 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 | 128 | export function ElementsPanel({ onAddElement }: ElementsPanelProps) { |
| 87 | 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 | 135 | Elements |
| 91 | 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 | 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 | 142 | {cat.title} |
| 98 | 143 | </div> |
| 99 | 144 | {cat.subtitle && ( | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
| ... | ... | @@ -225,8 +225,9 @@ function RulerBarHorizontal({ |
| 225 | 225 | } |
| 226 | 226 | const h = RULER_H; |
| 227 | 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 | 231 | const nodes: React.ReactNode[] = []; |
| 231 | 232 | |
| 232 | 233 | let labelStep = 1; |
| ... | ... | @@ -237,38 +238,37 @@ function RulerBarHorizontal({ |
| 237 | 238 | } |
| 238 | 239 | |
| 239 | 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 | 249 | const showLabel = k === 0 || k % labelStep === 0; |
| 245 | 250 | nodes.push( |
| 246 | 251 | <g key={`maj-${k}`}> |
| 247 | 252 | <line x1={x} y1={h} x2={x} y2={4} stroke="#9ca3af" strokeWidth={1} /> |
| 248 | 253 | {showLabel ? ( |
| 249 | 254 | <text |
| 250 | - x={k === 0 ? 3 : x} | |
| 255 | + x={x} | |
| 251 | 256 | y={12} |
| 252 | 257 | fontSize={8} |
| 253 | 258 | fill="#4b5563" |
| 254 | 259 | className="select-none font-mono" |
| 255 | - textAnchor={k === 0 ? "start" : "middle"} | |
| 260 | + textAnchor="middle" | |
| 256 | 261 | > |
| 257 | 262 | {k} |
| 258 | 263 | </text> |
| 259 | 264 | ) : null} |
| 260 | 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 | 267 | const midMinor = Math.floor(minorDivisions / 2); |
| 267 | 268 | for (let s = 1; s < minorDivisions; s++) { |
| 268 | 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 | 272 | const y2 = s === midMinor ? 10 : 12; |
| 273 | 273 | nodes.push( |
| 274 | 274 | <line |
| ... | ... | @@ -936,6 +936,8 @@ interface LabelCanvasProps { |
| 936 | 936 | scale?: number; |
| 937 | 937 | onZoomIn?: () => void; |
| 938 | 938 | onZoomOut?: () => void; |
| 939 | + /** 将缩放还原为 100%(与顶部标尺物理尺寸一致),并重新居中画布 */ | |
| 940 | + onResetZoom?: () => void; | |
| 939 | 941 | onPreview?: () => void; |
| 940 | 942 | /** 为 true 时不在预览工具栏显示画布尺寸预设(改由顶部表单控制) */ |
| 941 | 943 | hideToolbarPresetSize?: boolean; |
| ... | ... | @@ -969,6 +971,7 @@ export function LabelCanvas({ |
| 969 | 971 | scale = 1, |
| 970 | 972 | onZoomIn, |
| 971 | 973 | onZoomOut, |
| 974 | + onResetZoom, | |
| 972 | 975 | onPreview, |
| 973 | 976 | hideToolbarPresetSize = false, |
| 974 | 977 | }: LabelCanvasProps) { |
| ... | ... | @@ -1006,6 +1009,7 @@ export function LabelCanvas({ |
| 1006 | 1009 | |
| 1007 | 1010 | const baseW = unitToPx(template.width, template.unit); |
| 1008 | 1011 | const baseH = unitToPx(template.height, template.unit); |
| 1012 | + /** 缩放后的实际占位,用于滚动区域与居中,避免放大后画布被裁切 */ | |
| 1009 | 1013 | const widthPx = baseW * scale; |
| 1010 | 1014 | const heightPx = baseH * scale; |
| 1011 | 1015 | const showGrid = template.showGrid !== false; |
| ... | ... | @@ -1343,21 +1347,23 @@ export function LabelCanvas({ |
| 1343 | 1347 | }; |
| 1344 | 1348 | }, [paperResizeCursor]); |
| 1345 | 1349 | |
| 1346 | - // 画布初始居中:挂载或尺寸/缩放变化后让内容居中 | |
| 1347 | - useEffect(() => { | |
| 1350 | + const centerScrollInViewport = useCallback(() => { | |
| 1348 | 1351 | const el = scrollContainerRef.current; |
| 1349 | 1352 | if (!el) return; |
| 1350 | - const center = () => { | |
| 1353 | + const run = () => { | |
| 1351 | 1354 | el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2); |
| 1352 | 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 | 1368 | // Keyboard navigation for elements |
| 1363 | 1369 | const handleKeyDown = useCallback((e: React.KeyboardEvent) => { |
| ... | ... | @@ -1522,6 +1528,16 @@ export function LabelCanvas({ |
| 1522 | 1528 | + |
| 1523 | 1529 | </button> |
| 1524 | 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 | 1541 | <Select |
| 1526 | 1542 | value={previewRulerUnit} |
| 1527 | 1543 | onValueChange={(v: PreviewRulerDisplayUnit) => setPreviewRulerUnit(v)} |
| ... | ... | @@ -1587,12 +1603,12 @@ export function LabelCanvas({ |
| 1587 | 1603 | /> |
| 1588 | 1604 | </div> |
| 1589 | 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 | 1607 | <div |
| 1592 | 1608 | ref={canvasRef} |
| 1593 | 1609 | tabIndex={0} |
| 1594 | 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 | 1612 | canvasBorderClass, |
| 1597 | 1613 | isPanning ? 'cursor-grabbing' : 'cursor-grab' |
| 1598 | 1614 | )} |
| ... | ... | @@ -1600,6 +1616,7 @@ export function LabelCanvas({ |
| 1600 | 1616 | width: baseW, |
| 1601 | 1617 | height: baseH, |
| 1602 | 1618 | transform: `scale(${scale})`, |
| 1619 | + transformOrigin: 'top left', | |
| 1603 | 1620 | backgroundImage: showGrid |
| 1604 | 1621 | ? `linear-gradient(to right, rgba(0,0,0,0.06) 1px, transparent 1px), |
| 1605 | 1622 | linear-gradient(to bottom, rgba(0,0,0,0.06) 1px, transparent 1px)` |
| ... | ... | @@ -1657,18 +1674,38 @@ export function LabelCanvas({ |
| 1657 | 1674 | onPointerUp={handlePointerUp} |
| 1658 | 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 | 1709 | {/* Paper resize: top */} |
| 1673 | 1710 | {onTemplateChange && ( |
| 1674 | 1711 | <div |
| ... | ... | @@ -1755,7 +1792,7 @@ export function LabelCanvas({ |
| 1755 | 1792 | {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( |
| 1756 | 1793 | <div |
| 1757 | 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 | 1796 | style={{ |
| 1760 | 1797 | cursor: 'nwse-resize', |
| 1761 | 1798 | top: corner.startsWith('n') ? -6 : undefined, |
| ... | ... | @@ -1784,7 +1821,7 @@ export function LabelCanvas({ |
| 1784 | 1821 | {(['n', 's', 'w', 'e'] as const).map((edge) => ( |
| 1785 | 1822 | <div |
| 1786 | 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 | 1825 | style={{ |
| 1789 | 1826 | cursor: edge === 'n' || edge === 's' ? 'ns-resize' : 'ew-resize', |
| 1790 | 1827 | width: edge === 'n' || edge === 's' ? '20px' : '6px', |
| ... | ... | @@ -1823,6 +1860,46 @@ export function LabelCanvas({ |
| 1823 | 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 | 1903 | </div> |
| 1827 | 1904 | </div> |
| 1828 | 1905 | </div> | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
| ... | ... | @@ -19,7 +19,13 @@ import type { |
| 19 | 19 | Border, |
| 20 | 20 | NutritionExtraItem, |
| 21 | 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 | 29 | import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; |
| 24 | 30 | import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; |
| 25 | 31 | import { Checkbox } from '../../ui/checkbox'; |
| ... | ... | @@ -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 | 366 | function TextStaticStyleFields({ |
| 358 | 367 | cfg, |
| 359 | 368 | update, |
| 360 | 369 | textAlignDefault, |
| 370 | + primaryTextLabel, | |
| 361 | 371 | }: { |
| 362 | 372 | cfg: Record<string, unknown>; |
| 363 | 373 | update: (key: string, value: unknown) => void; |
| 364 | 374 | textAlignDefault: string; |
| 375 | + /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */ | |
| 376 | + primaryTextLabel?: 'Text' | 'Value'; | |
| 365 | 377 | }) { |
| 378 | + const textLabel = primaryTextLabel ?? 'Text'; | |
| 366 | 379 | return ( |
| 367 | 380 | <> |
| 368 | 381 | <div> |
| 369 | - <Label className="text-xs">Text</Label> | |
| 382 | + <Label className="text-xs">{textLabel}</Label> | |
| 370 | 383 | <Input |
| 371 | 384 | value={(cfg.text as string) ?? '0.00'} |
| 372 | 385 | onChange={(e) => update('text', e.target.value)} |
| ... | ... | @@ -480,6 +493,8 @@ function ElementConfigFields({ |
| 480 | 493 | const elementType = canonicalElementType(element.type); |
| 481 | 494 | const update = (key: string, value: unknown) => |
| 482 | 495 | onChange({ [key]: value }); |
| 496 | + const fromTemplatePalette = isTemplateSectionPersistedType(element); | |
| 497 | + const staticTextLabel = fromTemplatePalette ? ('Value' as const) : ('Text' as const); | |
| 483 | 498 | |
| 484 | 499 | switch (elementType) { |
| 485 | 500 | case 'TEXT_STATIC': |
| ... | ... | @@ -487,11 +502,23 @@ function ElementConfigFields({ |
| 487 | 502 | return ( |
| 488 | 503 | <> |
| 489 | 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 | 522 | case 'TEXT_PRODUCT': |
| 496 | 523 | case 'TEXT_PRICE': |
| 497 | 524 | return <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="right" />; |
| ... | ... | @@ -541,7 +568,41 @@ function ElementConfigFields({ |
| 541 | 568 | /> |
| 542 | 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 | 606 | return ( |
| 546 | 607 | <> |
| 547 | 608 | <div> |
| ... | ... | @@ -571,6 +632,7 @@ function ElementConfigFields({ |
| 571 | 632 | </div> |
| 572 | 633 | </> |
| 573 | 634 | ); |
| 635 | + } | |
| 574 | 636 | case 'DATE': { |
| 575 | 637 | const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase(); |
| 576 | 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 | 608 | scale={scale} |
| 609 | 609 | onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))} |
| 610 | 610 | onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))} |
| 611 | + onResetZoom={() => setScale(DEFAULT_SCALE)} | |
| 611 | 612 | onPreview={() => setPreviewOpen(true)} |
| 612 | 613 | hideToolbarPresetSize |
| 613 | 614 | /> | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
| ... | ... | @@ -74,8 +74,17 @@ import { |
| 74 | 74 | applyOffsetToDate, |
| 75 | 75 | formatDateByPreset, |
| 76 | 76 | LABEL_FORM_OFFSET_UNITS, |
| 77 | + normalizeLabelFormOffsetInput, | |
| 77 | 78 | serializePrintInputOffset, |
| 78 | 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 | 89 | function toDisplay(v: string | null | undefined): string { |
| 81 | 90 | const s = (v ?? "").trim(); |
| ... | ... | @@ -141,6 +150,7 @@ function buildCreateLabelPreviewTemplate( |
| 141 | 150 | apiTpl: LabelTemplateDto | null, |
| 142 | 151 | textValues: Record<string, string>, |
| 143 | 152 | dateOffsets: Record<string, { unit: string; value: string }>, |
| 153 | + nutritionByElementId: Record<string, Record<string, string>>, | |
| 144 | 154 | ): LabelTemplate | null { |
| 145 | 155 | if (!apiTpl) return null; |
| 146 | 156 | const tmpl = dtoToEditorTemplate(apiTpl); |
| ... | ... | @@ -153,11 +163,12 @@ function buildCreateLabelPreviewTemplate( |
| 153 | 163 | const type = canonicalElementType(el.type); |
| 154 | 164 | if (isDateTimeDataEntryField(el)) { |
| 155 | 165 | const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; |
| 156 | - const amount = Number(String(pair.value).trim()); | |
| 157 | 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 | 169 | cfg.__previewFormatted = ""; |
| 160 | 170 | } else { |
| 171 | + const amount = norm.kind === "zero" ? 0 : norm.amount; | |
| 161 | 172 | const d = applyOffsetToDate(now, amount, unit); |
| 162 | 173 | if (type === "DATE") { |
| 163 | 174 | const it = String(cfg.inputType ?? cfg.InputType ?? "").toLowerCase(); |
| ... | ... | @@ -185,6 +196,12 @@ function buildCreateLabelPreviewTemplate( |
| 185 | 196 | } |
| 186 | 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 | 205 | return tmpl; |
| 189 | 206 | } |
| 190 | 207 | |
| ... | ... | @@ -192,24 +209,32 @@ function collectTemplateDefaultValuesForSave( |
| 192 | 209 | latest: LabelTemplateDto, |
| 193 | 210 | textValues: Record<string, string>, |
| 194 | 211 | dateOffsets: Record<string, { unit: string; value: string }>, |
| 212 | + nutritionByElementId: Record<string, Record<string, string>>, | |
| 195 | 213 | ): Record<string, string> { |
| 196 | 214 | const out: Record<string, string> = {}; |
| 197 | 215 | for (const el of getDataEntryElements(latest)) { |
| 198 | 216 | const id = el.id; |
| 199 | 217 | if (isDateTimeDataEntryField(el)) { |
| 200 | 218 | const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; |
| 201 | - const amount = Number(String(pair.value).trim()); | |
| 202 | 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 | 222 | out[id] = ""; |
| 223 | + } else if (norm.kind === "zero") { | |
| 224 | + out[id] = serializePrintInputOffset(unit, "0"); | |
| 205 | 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 | 228 | } else { |
| 210 | 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 | 238 | return out; |
| 214 | 239 | } |
| 215 | 240 | |
| ... | ... | @@ -957,6 +982,8 @@ function CreateLabelDialog({ |
| 957 | 982 | const [templateDateOffsets, setTemplateDateOffsets] = useState< |
| 958 | 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 | 987 | const [form, setForm] = useState<LabelCreateInput>({ |
| 961 | 988 | labelCode: "", |
| 962 | 989 | labelName: "", |
| ... | ... | @@ -984,6 +1011,7 @@ function CreateLabelDialog({ |
| 984 | 1011 | setSelectedTemplate(null); |
| 985 | 1012 | setTemplateDataValues({}); |
| 986 | 1013 | setTemplateDateOffsets({}); |
| 1014 | + setNutritionByElementId({}); | |
| 987 | 1015 | setProductCatalogCategoryId(""); |
| 988 | 1016 | }; |
| 989 | 1017 | |
| ... | ... | @@ -1000,6 +1028,7 @@ function CreateLabelDialog({ |
| 1000 | 1028 | setSelectedTemplate(null); |
| 1001 | 1029 | setTemplateDataValues({}); |
| 1002 | 1030 | setTemplateDateOffsets({}); |
| 1031 | + setNutritionByElementId({}); | |
| 1003 | 1032 | return; |
| 1004 | 1033 | } |
| 1005 | 1034 | let cancelled = false; |
| ... | ... | @@ -1020,11 +1049,18 @@ function CreateLabelDialog({ |
| 1020 | 1049 | } |
| 1021 | 1050 | setTemplateDataValues(nextValues); |
| 1022 | 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 | 1058 | } catch (e: any) { |
| 1024 | 1059 | if (cancelled) return; |
| 1025 | 1060 | setSelectedTemplate(null); |
| 1026 | 1061 | setTemplateDataValues({}); |
| 1027 | 1062 | setTemplateDateOffsets({}); |
| 1063 | + setNutritionByElementId({}); | |
| 1028 | 1064 | toast.error("Failed to load template fields.", { |
| 1029 | 1065 | description: e?.message ? String(e.message) : "Please select another template.", |
| 1030 | 1066 | }); |
| ... | ... | @@ -1058,12 +1094,16 @@ function CreateLabelDialog({ |
| 1058 | 1094 | const labelTypeId = form.labelTypeId.trim(); |
| 1059 | 1095 | if (!labelTypeId) return; |
| 1060 | 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 | 1102 | const inputDefaultValues = collectTemplateDefaultValuesForSave( |
| 1064 | 1103 | latest, |
| 1065 | 1104 | templateDataValues, |
| 1066 | 1105 | templateDateOffsets, |
| 1106 | + nutritionByElementId, | |
| 1067 | 1107 | ); |
| 1068 | 1108 | const defaultsMap = buildTemplateDefaultsMap(latest); |
| 1069 | 1109 | for (const productId of form.productIds) { |
| ... | ... | @@ -1165,39 +1205,77 @@ function CreateLabelDialog({ |
| 1165 | 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 | 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 | 1232 | const hasTemplateSelected = form.templateCode.trim().length > 0; |
| 1173 | 1233 | |
| 1174 | 1234 | return ( |
| 1175 | 1235 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 1176 | 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 | 1238 | style={{ |
| 1179 | 1239 | width: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", |
| 1180 | 1240 | maxWidth: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", |
| 1181 | - maxHeight: "86vh", | |
| 1182 | 1241 | }} |
| 1183 | 1242 | > |
| 1184 | - <DialogHeader> | |
| 1243 | + <DialogHeader className="shrink-0"> | |
| 1185 | 1244 | <DialogTitle>Add New Label</DialogTitle> |
| 1186 | 1245 | <DialogDescription>Enter the details for the new label.</DialogDescription> |
| 1187 | 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 | 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 | 1261 | style={ |
| 1193 | 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 | 1279 | <div className="text-sm font-semibold text-gray-900">General Settings</div> |
| 1202 | 1280 | <div className="space-y-2 mt-3 mb-2"> |
| 1203 | 1281 | <ProductSingleSelectByCategoryField |
| ... | ... | @@ -1289,15 +1367,13 @@ function CreateLabelDialog({ |
| 1289 | 1367 | </div> |
| 1290 | 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 | 1372 | <div className="text-sm font-semibold text-gray-900 mb-3">Template Input Data</div> |
| 1295 | 1373 | {templateLoading ? ( |
| 1296 | 1374 | <div className="text-sm text-gray-500">Loading template fields...</div> |
| 1297 | 1375 | ) : !form.templateCode.trim() ? ( |
| 1298 | 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 | 1378 | <div className="space-y-3"> |
| 1303 | 1379 | {dataEntryElements.map((el) => ( |
| ... | ... | @@ -1353,9 +1429,35 @@ function CreateLabelDialog({ |
| 1353 | 1429 | )} |
| 1354 | 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 | 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 | 1461 | </div> |
| 1360 | 1462 | </div> |
| 1361 | 1463 | )} |
| ... | ... | @@ -1363,10 +1465,7 @@ function CreateLabelDialog({ |
| 1363 | 1465 | ) : null} |
| 1364 | 1466 | |
| 1365 | 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 | 1469 | <div className="text-sm font-semibold text-gray-900 mb-3">Label Preview</div> |
| 1371 | 1470 | {previewTemplate ? ( |
| 1372 | 1471 | <div className="flex justify-center w-full min-w-0 overflow-hidden"> |
| ... | ... | @@ -1380,7 +1479,7 @@ function CreateLabelDialog({ |
| 1380 | 1479 | </div> |
| 1381 | 1480 | </div> |
| 1382 | 1481 | |
| 1383 | - <DialogFooter> | |
| 1482 | + <DialogFooter className="shrink-0"> | |
| 1384 | 1483 | <Button variant="outline" onClick={() => onOpenChange(false)}> |
| 1385 | 1484 | Cancel |
| 1386 | 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 | 2 | import { LabelsList } from './LabelsList'; |
| 3 | 3 | import { LabelCategoriesView } from './LabelCategoriesView'; |
| 4 | 4 | import { LabelTypesView } from './LabelTypesView'; |
| ... | ... | @@ -13,6 +13,8 @@ interface LabelsViewProps { |
| 13 | 13 | /** Dashboard「New Label」递增;由 Labels 列表消费后应调用 onLabelCreateIntentConsumed */ |
| 14 | 14 | labelCreateOpenSeq?: number; |
| 15 | 15 | onLabelCreateIntentConsumed?: () => void; |
| 16 | + /** 标签模板新增/编辑时 true,用于布局层隐藏侧栏与顶栏 */ | |
| 17 | + onLabelTemplateEditorLayoutOverlay?: (fullscreen: boolean) => void; | |
| 16 | 18 | } |
| 17 | 19 | |
| 18 | 20 | export function LabelsView({ |
| ... | ... | @@ -20,9 +22,18 @@ export function LabelsView({ |
| 20 | 22 | onViewChange, |
| 21 | 23 | labelCreateOpenSeq = 0, |
| 22 | 24 | onLabelCreateIntentConsumed, |
| 25 | + onLabelTemplateEditorLayoutOverlay, | |
| 23 | 26 | }: LabelsViewProps) { |
| 24 | 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 | 37 | const tabs: Tab[] = [ |
| 27 | 38 | 'Labels', |
| 28 | 39 | 'Label Categories', |
| ... | ... | @@ -89,7 +100,7 @@ export function LabelsView({ |
| 89 | 100 | )} |
| 90 | 101 | {currentView === 'Label Templates' && ( |
| 91 | 102 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| 92 | - <LabelTemplatesView onTemplateEditorOverlayChange={setTemplateEditorHidesTabs} /> | |
| 103 | + <LabelTemplatesView onTemplateEditorOverlayChange={handleTemplateEditorOverlay} /> | |
| 93 | 104 | </div> |
| 94 | 105 | )} |
| 95 | 106 | {currentView === 'Multiple Options' && ( | ... | ... |
美国版/Food Labeling Management Platform/src/components/layout/Layout.tsx
| ... | ... | @@ -10,31 +10,50 @@ interface LayoutProps { |
| 10 | 10 | setCurrentView: (view: string) => void; |
| 11 | 11 | menus?: CurrentUserMenuNodeDto[]; |
| 12 | 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 | 25 | return ( |
| 17 | 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 | 57 | </main> |
| 39 | 58 | </div> |
| 40 | 59 | </div> | ... | ... |
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
| 1 | 1 | import React, { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | 2 | import { Edit, MapPin, MoreHorizontal, Trash2 } from "lucide-react"; |
| 3 | 3 | import { Button } from "../ui/button"; |
| 4 | +import { Checkbox } from "../ui/checkbox"; | |
| 4 | 5 | import { Input } from "../ui/input"; |
| 5 | 6 | import { |
| 6 | 7 | Table, |
| ... | ... | @@ -30,7 +31,6 @@ import { Badge } from "../ui/badge"; |
| 30 | 31 | import { Switch } from "../ui/switch"; |
| 31 | 32 | import { toast } from "sonner"; |
| 32 | 33 | import { skipCountForPage } from "../../lib/paginationQuery"; |
| 33 | -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; | |
| 34 | 34 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; |
| 35 | 35 | import { |
| 36 | 36 | Pagination, |
| ... | ... | @@ -40,12 +40,22 @@ import { |
| 40 | 40 | PaginationNext, |
| 41 | 41 | PaginationPrevious, |
| 42 | 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 | 52 | import { getPartners } from "../../services/partnerService"; |
| 45 | 53 | import { getGroups } from "../../services/groupService"; |
| 46 | 54 | import type { LocationCreateInput, LocationDto } from "../../types/location"; |
| 47 | 55 | import type { GroupListItem } from "../../types/group"; |
| 48 | 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 | 60 | const LOCATION_PG_NONE = "__none__"; |
| 51 | 61 | |
| ... | ... | @@ -137,6 +147,12 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { |
| 137 | 147 | const [total, setTotal] = useState(0); |
| 138 | 148 | const [refreshSeq, setRefreshSeq] = useState(0); |
| 139 | 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 | 157 | const [keyword, setKeyword] = useState(""); |
| 142 | 158 | const [partner, setPartner] = useState<string>("all"); |
| ... | ... | @@ -158,6 +174,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { |
| 158 | 174 | }; |
| 159 | 175 | }, [keyword]); |
| 160 | 176 | |
| 177 | + const listKeyword = useMemo( | |
| 178 | + () => (locationPick !== "all" ? locationPick : debouncedKeyword.trim()), | |
| 179 | + [locationPick, debouncedKeyword], | |
| 180 | + ); | |
| 181 | + | |
| 161 | 182 | // Options derived from current result set (no dedicated endpoints provided in doc). |
| 162 | 183 | const partnerOptions = useMemo(() => { |
| 163 | 184 | const s = new Set<string>(); |
| ... | ... | @@ -203,12 +224,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { |
| 203 | 224 | setLoading(true); |
| 204 | 225 | try { |
| 205 | 226 | const skipCount = skipCountForPage(pageIndex); |
| 206 | - const effectiveKeyword = locationPick !== "all" ? locationPick : debouncedKeyword; | |
| 207 | 227 | const res = await getLocations( |
| 208 | 228 | { |
| 209 | 229 | skipCount, |
| 210 | 230 | maxResultCount: pageSize, |
| 211 | - keyword: effectiveKeyword || undefined, | |
| 231 | + keyword: listKeyword || undefined, | |
| 212 | 232 | partner: partner !== "all" ? partner : undefined, |
| 213 | 233 | groupName: groupName !== "all" ? groupName : undefined, |
| 214 | 234 | }, |
| ... | ... | @@ -231,7 +251,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { |
| 231 | 251 | |
| 232 | 252 | run(); |
| 233 | 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 | 260 | const refreshList = () => setRefreshSeq((x) => x + 1); |
| 237 | 261 | |
| ... | ... | @@ -295,36 +319,54 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { |
| 295 | 319 | </SelectContent> |
| 296 | 320 | </Select> |
| 297 | 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 | 370 | <Button |
| 329 | 371 | className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" |
| 330 | 372 | onClick={() => setIsCreateDialogOpen(true)} |
| ... | ... | @@ -343,6 +385,16 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { |
| 343 | 385 | <Table> |
| 344 | 386 | <TableHeader> |
| 345 | 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 | 398 | <TableHead className="text-gray-900 font-bold border-r">Company</TableHead> |
| 347 | 399 | <TableHead className="text-gray-900 font-bold border-r">Region</TableHead> |
| 348 | 400 | <TableHead className="text-gray-900 font-bold border-r">Location ID</TableHead> |
| ... | ... | @@ -362,19 +414,33 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { |
| 362 | 414 | <TableBody> |
| 363 | 415 | {loading ? ( |
| 364 | 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 | 418 | Loading... |
| 367 | 419 | </TableCell> |
| 368 | 420 | </TableRow> |
| 369 | 421 | ) : locations.length === 0 ? ( |
| 370 | 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 | 424 | No results. |
| 373 | 425 | </TableCell> |
| 374 | 426 | </TableRow> |
| 375 | 427 | ) : ( |
| 376 | 428 | locations.map((loc) => ( |
| 377 | 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 | 444 | <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.partner)}</TableCell> |
| 379 | 445 | <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.groupName)}</TableCell> |
| 380 | 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 | 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 | 68 | import { |
| 69 | 69 | createTeamMember, |
| 70 | 70 | deleteTeamMember, |
| 71 | + downloadTeamMemberImportTemplate, | |
| 72 | + exportTeamMembersPdf, | |
| 71 | 73 | getTeamMemberById, |
| 72 | 74 | getTeamMembers, |
| 75 | + importTeamMembersBatch, | |
| 73 | 76 | updateTeamMember, |
| 74 | 77 | } from "../../services/teamMemberService"; |
| 75 | 78 | import type { LocationDto } from "../../types/location"; |
| ... | ... | @@ -85,6 +88,8 @@ import { createGroup, deleteGroup, exportGroupsPdf, getGroups, updateGroup } fro |
| 85 | 88 | import type { GroupListItem } from "../../types/group"; |
| 86 | 89 | import type { PartnerListItem } from "../../types/partner"; |
| 87 | 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 | 94 | function downloadBlob(blob: Blob, filename: string) { |
| 90 | 95 | const url = URL.createObjectURL(blob); |
| ... | ... | @@ -171,6 +176,13 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie |
| 171 | 176 | } |
| 172 | 177 | }, [initialSubTab, onInitialSubTabConsumed]); |
| 173 | 178 | |
| 179 | + useEffect(() => { | |
| 180 | + if (activeTab !== "Team Member") { | |
| 181 | + setMemberBulkEditPage(false); | |
| 182 | + setMemberBulkEditSeed([]); | |
| 183 | + } | |
| 184 | + }, [activeTab]); | |
| 185 | + | |
| 174 | 186 | // Data States |
| 175 | 187 | const [roles, setRoles] = useState<RoleDto[]>([]); |
| 176 | 188 | const [roleTotal, setRoleTotal] = useState(0); |
| ... | ... | @@ -228,6 +240,12 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie |
| 228 | 240 | const [editingMember, setEditingMember] = useState<TeamMemberDto | null>(null); |
| 229 | 241 | const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false); |
| 230 | 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 | 250 | // Dialog States |
| 233 | 251 | const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); |
| ... | ... | @@ -299,6 +317,10 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie |
| 299 | 317 | }, [memberKeyword]); |
| 300 | 318 | |
| 301 | 319 | useEffect(() => { |
| 320 | + setSelectedMemberIds(new Set()); | |
| 321 | + }, [debouncedMemberKeyword, memberPageIndex]); | |
| 322 | + | |
| 323 | + useEffect(() => { | |
| 302 | 324 | if (partnerKeywordTimerRef.current) window.clearTimeout(partnerKeywordTimerRef.current); |
| 303 | 325 | partnerKeywordTimerRef.current = window.setTimeout(() => setDebouncedPartnerKeyword(partnerKeyword.trim()), 300); |
| 304 | 326 | return () => { |
| ... | ... | @@ -545,8 +567,6 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie |
| 545 | 567 | ); |
| 546 | 568 | |
| 547 | 569 | const renderToolbar = () => { |
| 548 | - const canBulkOps = activeTab === "Team Member"; | |
| 549 | - | |
| 550 | 570 | return ( |
| 551 | 571 | <div className="flex flex-col gap-4 pb-4"> |
| 552 | 572 | {/* Search + Actions - one row, style consistent with Labels / Location Manager */} |
| ... | ... | @@ -619,19 +639,63 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie |
| 619 | 639 | </> |
| 620 | 640 | )} |
| 621 | 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 | 650 | Bulk Import |
| 626 | 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 | 690 | Bulk Edit |
| 629 | 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 | 699 | <Button |
| 636 | 700 | className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" |
| 637 | 701 | onClick={openCreateDialog} |
| ... | ... | @@ -1032,6 +1096,16 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie |
| 1032 | 1096 | <Table> |
| 1033 | 1097 | <TableHeader> |
| 1034 | 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 | 1109 | <TableHead className="font-bold text-black border-r">Name</TableHead> |
| 1036 | 1110 | <TableHead className="font-bold text-black border-r">Email</TableHead> |
| 1037 | 1111 | <TableHead className="font-bold text-black border-r">Phone</TableHead> |
| ... | ... | @@ -1044,19 +1118,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie |
| 1044 | 1118 | <TableBody> |
| 1045 | 1119 | {membersLoading ? ( |
| 1046 | 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 | 1122 | Loading... |
| 1049 | 1123 | </TableCell> |
| 1050 | 1124 | </TableRow> |
| 1051 | 1125 | ) : members.length === 0 ? ( |
| 1052 | 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 | 1128 | No results. |
| 1055 | 1129 | </TableCell> |
| 1056 | 1130 | </TableRow> |
| 1057 | 1131 | ) : ( |
| 1058 | 1132 | members.map((m) => ( |
| 1059 | 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 | 1148 | <TableCell className="font-medium border-r">{m.fullName ?? m.userName ?? "N/A"}</TableCell> |
| 1061 | 1149 | <TableCell className="border-r text-gray-600">{m.email ?? "N/A"}</TableCell> |
| 1062 | 1150 | <TableCell className="border-r text-gray-600">{m.phone ?? "N/A"}</TableCell> |
| ... | ... | @@ -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 | 1279 | return ( |
| 1190 | 1280 | <div className="h-full flex flex-col"> |
| 1191 | - {activeTab !== "Location Manager" ? renderToolbar() : null} | |
| 1281 | + {activeTab !== "Location Manager" && !showTeamMemberBulkPage ? renderToolbar() : null} | |
| 1192 | 1282 | |
| 1193 | 1283 | {activeTab === "Location Manager" ? ( |
| 1194 | 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 | 1305 | <div className="flex-1 overflow-auto pt-6"> |
| 1197 | 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 | 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 | 1422 | <DeleteMemberDialog |
| 1290 | 1423 | open={isDeleteMemberDialogOpen} |
| 1291 | 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 | 10 | Trash2, |
| 11 | 11 | } from "lucide-react"; |
| 12 | 12 | import { Button } from "../ui/button"; |
| 13 | +import { Checkbox } from "../ui/checkbox"; | |
| 13 | 14 | import { Input } from "../ui/input"; |
| 14 | 15 | import { |
| 15 | 16 | Table, |
| ... | ... | @@ -66,8 +67,11 @@ import { |
| 66 | 67 | import { |
| 67 | 68 | createProduct, |
| 68 | 69 | deleteProduct, |
| 70 | + downloadProductImportTemplate, | |
| 71 | + exportProductsExcel, | |
| 69 | 72 | getProduct, |
| 70 | 73 | getProducts, |
| 74 | + importProductsBatch, | |
| 71 | 75 | updateProduct, |
| 72 | 76 | } from "../../services/productService"; |
| 73 | 77 | import { getProductIdsByLocation, getProductLocations } from "../../services/productLocationService"; |
| ... | ... | @@ -76,6 +80,8 @@ import type { ProductDto, ProductCreateInput, ProductUpdateInput } from "../../t |
| 76 | 80 | import type { ProductCategoryDto, ProductCategoryCreateInput } from "../../types/productCategory"; |
| 77 | 81 | import { SearchableSelect } from "../ui/searchable-select"; |
| 78 | 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 | 85 | import { |
| 80 | 86 | Pagination, |
| 81 | 87 | PaginationContent, |
| ... | ... | @@ -162,6 +168,12 @@ export function ProductsView() { |
| 162 | 168 | const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null); |
| 163 | 169 | const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null); |
| 164 | 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 | 178 | useEffect(() => { |
| 167 | 179 | if (keywordTimer.current) window.clearTimeout(keywordTimer.current); |
| ... | ... | @@ -341,6 +353,10 @@ export function ProductsView() { |
| 341 | 353 | |
| 342 | 354 | const refresh = () => setRefreshSeq((x) => x + 1); |
| 343 | 355 | |
| 356 | + useEffect(() => { | |
| 357 | + setSelectedProductIds(new Set()); | |
| 358 | + }, [debouncedKeyword, locationFilter, categoryFilter, stateFilter, pageIndex]); | |
| 359 | + | |
| 344 | 360 | const refreshCategories = () => { |
| 345 | 361 | setCatRefreshSeq((x) => x + 1); |
| 346 | 362 | reloadCategoryCatalog(); |
| ... | ... | @@ -461,38 +477,6 @@ export function ProductsView() { |
| 461 | 477 | </SelectContent> |
| 462 | 478 | </Select> |
| 463 | 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 | 480 | </div> |
| 497 | 481 | |
| 498 | 482 | <div className="w-full border-b border-gray-200 mt-4"> |
| ... | ... | @@ -533,12 +517,101 @@ export function ProductsView() { |
| 533 | 517 | </div> |
| 534 | 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 | 600 | {activeTab === "products" ? ( |
| 538 | 601 | <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden"> |
| 539 | 602 | <Table> |
| 540 | 603 | <TableHeader> |
| 541 | 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 | 615 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Location</TableHead> |
| 543 | 616 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product Category</TableHead> |
| 544 | 617 | <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product</TableHead> |
| ... | ... | @@ -551,13 +624,13 @@ export function ProductsView() { |
| 551 | 624 | <TableBody> |
| 552 | 625 | {loading ? ( |
| 553 | 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 | 628 | Loading... |
| 556 | 629 | </TableCell> |
| 557 | 630 | </TableRow> |
| 558 | 631 | ) : products.length === 0 ? ( |
| 559 | 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 | 634 | No products found. |
| 562 | 635 | </TableCell> |
| 563 | 636 | </TableRow> |
| ... | ... | @@ -571,6 +644,20 @@ export function ProductsView() { |
| 571 | 644 | const active = p.state !== false; |
| 572 | 645 | return ( |
| 573 | 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 | 661 | <TableCell className="border-r text-sm max-w-[200px] truncate" title={locText}> |
| 575 | 662 | {locText} |
| 576 | 663 | </TableCell> |
| ... | ... | @@ -657,10 +744,11 @@ export function ProductsView() { |
| 657 | 744 | )} |
| 658 | 745 | </TableBody> |
| 659 | 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 | 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 | 752 | </span> |
| 665 | 753 | <div className="flex items-center gap-2"> |
| 666 | 754 | <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> |
| ... | ... | @@ -675,27 +763,34 @@ export function ProductsView() { |
| 675 | 763 | ))} |
| 676 | 764 | </SelectContent> |
| 677 | 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 | 794 | </div> |
| 700 | 795 | </div> |
| 701 | 796 | </div> |
| ... | ... | @@ -846,8 +941,9 @@ export function ProductsView() { |
| 846 | 941 | |
| 847 | 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 | 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 | 947 | </div> |
| 852 | 948 | <div className="flex items-center gap-3"> |
| 853 | 949 | <Select value={String(catPageSize)} onValueChange={(v) => setCatPageSize(Number(v))}> |
| ... | ... | @@ -927,6 +1023,43 @@ export function ProductsView() { |
| 927 | 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 | 1063 | <ProductCategoryFormDialog |
| 931 | 1064 | open={isProductCategoryDialogOpen} |
| 932 | 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 | 3 | import { Search, Download, Printer, Calendar as CalendarIcon, BarChart3, LineChart, ArrowUpRight, RefreshCw, FileText } from "lucide-react"; |
| 3 | 4 | import { Button } from "../ui/button"; |
| 4 | 5 | import { Input } from "../ui/input"; |
| ... | ... | @@ -16,11 +17,9 @@ import { getLocations } from "../../services/locationService"; |
| 16 | 17 | import { getPartners } from "../../services/partnerService"; |
| 17 | 18 | import { getGroups } from "../../services/groupService"; |
| 18 | 19 | import { |
| 19 | - exportLabelReportPdf, | |
| 20 | - exportPrintLogPdf, | |
| 20 | + exportPrintLogExcel, | |
| 21 | 21 | getLabelReport, |
| 22 | 22 | getReportsPrintLogList, |
| 23 | - reprintPrintLog, | |
| 24 | 23 | } from "../../services/reportsService"; |
| 25 | 24 | import type { LocationDto } from "../../types/location"; |
| 26 | 25 | import type { PartnerListItem } from "../../types/partner"; |
| ... | ... | @@ -98,6 +97,62 @@ function formatTrendLabel(iso: string): string { |
| 98 | 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 | 156 | function templateCell(template: string) { |
| 102 | 157 | const t = template ?? ""; |
| 103 | 158 | if (!t.trim()) return <span className="text-gray-500">None</span>; |
| ... | ... | @@ -169,7 +224,6 @@ export function ReportsView({ |
| 169 | 224 | const [labelData, setLabelData] = useState<LabelReportData | null>(null); |
| 170 | 225 | const [labelLoading, setLabelLoading] = useState(false); |
| 171 | 226 | const [exporting, setExporting] = useState(false); |
| 172 | - const [reprintBusyId, setReprintBusyId] = useState<string | null>(null); | |
| 173 | 227 | const [filterMetaLoading, setFilterMetaLoading] = useState(true); |
| 174 | 228 | |
| 175 | 229 | const printAbortRef = useRef<AbortController | null>(null); |
| ... | ... | @@ -350,41 +404,17 @@ export function ReportsView({ |
| 350 | 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 | 407 | const handleExport = async () => { |
| 374 | 408 | const f = buildReportFilters(); |
| 375 | 409 | setExporting(true); |
| 376 | 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 | 418 | } catch (e) { |
| 389 | 419 | const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again."; |
| 390 | 420 | toast.error("Export failed", { description: msg }); |
| ... | ... | @@ -445,57 +475,14 @@ export function ReportsView({ |
| 445 | 475 | ))} |
| 446 | 476 | </SelectContent> |
| 447 | 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 | 486 | <div |
| 500 | 487 | className="flex items-center w-64 rounded-md border border-gray-300 bg-white overflow-hidden shrink-0" |
| 501 | 488 | style={{ height: 40 }} |
| ... | ... | @@ -509,15 +496,17 @@ export function ReportsView({ |
| 509 | 496 | /> |
| 510 | 497 | </div> |
| 511 | 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 | 510 | </div> |
| 522 | 511 | |
| 523 | 512 | <div className="w-full border-b border-gray-200 mt-4"> |
| ... | ... | @@ -599,17 +588,13 @@ export function ReportsView({ |
| 599 | 588 | <TableCell className="border-r text-gray-600 text-sm font-numeric">{toDisplay(log.locationText)}</TableCell> |
| 600 | 589 | <TableCell className="border-r text-sm font-mono text-gray-800">{toDisplay(log.expiryDateText)}</TableCell> |
| 601 | 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 | 598 | </TableCell> |
| 614 | 599 | </TableRow> |
| 615 | 600 | ))} |
| ... | ... | @@ -633,7 +618,12 @@ export function ReportsView({ |
| 633 | 618 | /> |
| 634 | 619 | </PaginationItem> |
| 635 | 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 | 627 | Page {pageIndex} / {totalPages} |
| 638 | 628 | </PaginationLink> |
| 639 | 629 | </PaginationItem> | ... | ... |
美国版/Food Labeling Management Platform/src/components/ui/calendar.tsx
| ... | ... | @@ -18,7 +18,7 @@ function Calendar({ |
| 18 | 18 | showOutsideDays={showOutsideDays} |
| 19 | 19 | className={cn("p-3", className)} |
| 20 | 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 | 22 | month: "flex flex-col gap-4", |
| 23 | 23 | caption: "flex justify-center pt-1 relative items-center w-full", |
| 24 | 24 | caption_label: "text-sm font-medium", |
| ... | ... | @@ -29,20 +29,21 @@ function Calendar({ |
| 29 | 29 | ), |
| 30 | 30 | nav_button_previous: "absolute left-1", |
| 31 | 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 | 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 | 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 | 40 | props.mode === "range" |
| 40 | 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 | 42 | : "[&:has([aria-selected])]:rounded-md", |
| 42 | 43 | ), |
| 43 | 44 | day: cn( |
| 44 | 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 | 48 | day_range_start: |
| 48 | 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 | 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 | 81 | return ( |
| 79 | 82 | <div className={cn("space-y-2", className)}> |
| ... | ... | @@ -94,14 +97,14 @@ export function ImageUrlUpload({ |
| 94 | 97 | onClick={openPicker} |
| 95 | 98 | aria-label={emptyLabel || "Upload image"} |
| 96 | 99 | className={cn( |
| 97 | - boxBase, | |
| 100 | + boxShell, | |
| 101 | + hasCustomBox ? boxClassName : null, | |
| 98 | 102 | "flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400", |
| 99 | 103 | emptyLabel && !uploading |
| 100 | 104 | ? "flex-col items-center justify-center gap-2" |
| 101 | 105 | : "items-center justify-center", |
| 102 | 106 | "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500", |
| 103 | 107 | "disabled:pointer-events-none disabled:opacity-50", |
| 104 | - boxClassName, | |
| 105 | 108 | )} |
| 106 | 109 | > |
| 107 | 110 | {uploading ? ( |
| ... | ... | @@ -120,9 +123,9 @@ export function ImageUrlUpload({ |
| 120 | 123 | ) : ( |
| 121 | 124 | <div |
| 122 | 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 | 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 | 98 | |
| 99 | 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 | 120 | * 模板 productDefault 中日期/时间/时长录入:存 `{"unit":"Days","value":"2"}`,供 App 与打印按 BaseTime 解析。 |
| 103 | 121 | */ |
| ... | ... | @@ -146,9 +164,10 @@ export function resolveStoredPrintValueToDisplayText( |
| 146 | 164 | if (!isDateTimeDataEntryField(el)) return s; |
| 147 | 165 | const parsed = tryParsePrintInputOffsetStored(s); |
| 148 | 166 | if (!parsed) return s; |
| 149 | - const amount = Number(String(parsed.value).trim()); | |
| 150 | 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 | 171 | const type = canonicalElementType(el.type); |
| 153 | 172 | const d = applyOffsetToDate(base, amount, unit); |
| 154 | 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
美国版/Food Labeling Management Platform/src/services/locationService.ts
| 1 | 1 | import { createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; | |
| 2 | 3 | import type { |
| 3 | 4 | LocationCreateInput, |
| 4 | 5 | LocationDto, |
| ... | ... | @@ -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 | 1 | import { createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; | |
| 2 | 3 | import type { |
| 3 | 4 | ProductCreateInput, |
| 4 | 5 | ProductDto, |
| ... | ... | @@ -116,3 +117,70 @@ export async function deleteProduct(id: string): Promise<void> { |
| 116 | 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 | 1 | import { ApiError, createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload } from "../lib/batchFileHttp"; | |
| 2 | 3 | import type { |
| 3 | 4 | LabelReportData, |
| 4 | 5 | LabelReportQueryInput, |
| ... | ... | @@ -385,6 +386,23 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi |
| 385 | 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 | 406 | export async function exportLabelReportPdf(input: LabelReportQueryInput): Promise<void> { |
| 389 | 407 | const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; |
| 390 | 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 | 2 | import type { PagedResultDto, RoleDto, RoleGetListInput } from "../types/role"; |
| 3 | 3 | |
| 4 | 4 | const api = createApiClient({ |
| ... | ... | @@ -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 | 1 | import { createApiClient } from "../lib/apiClient"; |
| 2 | +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; | |
| 2 | 3 | import type { |
| 3 | 4 | PagedResultDto, |
| 4 | 5 | TeamMemberCreateInput, |
| ... | ... | @@ -152,6 +153,10 @@ export async function getTeamMembers( |
| 152 | 153 | SkipCount: input.skipCount, |
| 153 | 154 | MaxResultCount: input.maxResultCount, |
| 154 | 155 | Keyword: input.keyword, |
| 156 | + RoleId: input.roleId, | |
| 157 | + LocationId: input.locationId, | |
| 158 | + State: input.state, | |
| 159 | + Sorting: input.sorting, | |
| 155 | 160 | }, |
| 156 | 161 | signal, |
| 157 | 162 | }); |
| ... | ... | @@ -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 | 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 | 615 | export function isDataEntryTableColumnElement(el: LabelElement): boolean { |
| 608 | 616 | const persistedType = resolvedTypeAddForPersist(el).trim().toLowerCase(); |
| 617 | + if (isTemplateSectionPersistedType(el)) return false; | |
| 609 | 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 | 619 | "label_label name", |
| 617 | 620 | "label_text", |
| 618 | 621 | "label_qr code", |
| ... | ... | @@ -626,7 +629,6 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean { |
| 626 | 629 | "label_how-to", |
| 627 | 630 | "label_expiration alert", |
| 628 | 631 | ]); |
| 629 | - const type = canonicalElementType(el.type); | |
| 630 | 632 | const vst = normalizeValueSourceTypeForElement(el); |
| 631 | 633 | if (isBlankSpaceElement(el)) return false; |
| 632 | 634 | if (!manualTypeAddWhitelist.has(persistedType)) return false; | ... | ... |
美国版/Food Labeling Management Platform/src/types/teamMember.ts
| ... | ... | @@ -27,6 +27,10 @@ export type TeamMemberGetListInput = { |
| 27 | 27 | skipCount: number; // pageIndex (1-based) |
| 28 | 28 | maxResultCount: number; // pageSize |
| 29 | 29 | keyword?: string; |
| 30 | + roleId?: string; | |
| 31 | + locationId?: string; | |
| 32 | + state?: boolean; | |
| 33 | + sorting?: string; | |
| 30 | 34 | }; |
| 31 | 35 | |
| 32 | 36 | export type TeamMemberCreateInput = { | ... | ... |