Commit 632897232c7f7bf429932216cdfdf29ddd7b5430

Authored by 杨鑫
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 &#39;../../utils/statusBar&#39;
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(() =&gt; {
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 = () =&gt; {
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
1   -using JetBrains.Annotations;
  1 +using JetBrains.Annotations;
2 2 using Microsoft.AspNetCore.Mvc.ApplicationModels;
3 3 using System.Reflection;
4 4 using Microsoft.Extensions.DependencyInjection;
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application.Contracts/IServices/IRoleService.cs
... ... @@ -23,5 +23,12 @@ namespace Yi.Framework.Rbac.Application.Contracts.IServices
23 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&apos;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&apos;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 &quot;now&quot;;
  1458 + other numbers add that offset. Format follows each field&apos;s template setting. On save, values
  1459 + are written for the selected product. Nutrition columns follow the template&apos;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 &quot;../ui/badge&quot;;
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 &quot;../../services/locationService&quot;;
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 &quot;../../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 &quot;../../services/locationService&quot;;
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
1 1  
2 2 import { createRoot } from "react-dom/client";
3 3 import App from "./App.tsx";
  4 + import "react-day-picker/dist/style.css";
4 5 import "./index.css";
5 6 import "./styles/fonts.css";
6 7  
... ...
美国版/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&lt;void&gt; {
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&lt;void&gt; {
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&lt;void&gt; {
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 = {
... ...