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,15 +111,6 @@ const items = [
111 { key: 'home', path: '/pages/index/index', icon: 'home', labelKey: 'Home' }, 111 { key: 'home', path: '/pages/index/index', icon: 'home', labelKey: 'Home' },
112 { key: 'Labeling', path: '/pages/labels/labels', icon: 'tag', labelKey: 'Labeling' }, 112 { key: 'Labeling', path: '/pages/labels/labels', icon: 'tag', labelKey: 'Labeling' },
113 { 113 {
114 - key: 'categories',  
115 - icon: 'squares',  
116 - labelKey: 'categories.menuGroup',  
117 - children: [  
118 - { path: '/pages/categories/product-categories', labelKey: 'categories.productCategories' },  
119 - { path: '/pages/categories/label-categories', labelKey: 'categories.labelCategories' },  
120 - ],  
121 - },  
122 - {  
123 key: 'report', 114 key: 'report',
124 icon: 'fileText', 115 icon: 'fileText',
125 labelKey: 'more.report', 116 labelKey: 'more.report',
@@ -147,12 +138,6 @@ watch( @@ -147,12 +138,6 @@ watch(
147 if (currentPath.value.startsWith('/pages/more/print-log') || currentPath.value.startsWith('/pages/more/label-report')) { 138 if (currentPath.value.startsWith('/pages/more/print-log') || currentPath.value.startsWith('/pages/more/label-report')) {
148 expandedKey.value = 'report' 139 expandedKey.value = 'report'
149 } 140 }
150 - if (  
151 - currentPath.value.startsWith('/pages/categories/product-categories') ||  
152 - currentPath.value.startsWith('/pages/categories/label-categories')  
153 - ) {  
154 - expandedKey.value = 'categories'  
155 - }  
156 nextTick(() => { 141 nextTick(() => {
157 animClass.value = 'opening' 142 animClass.value = 'opening'
158 }) 143 })
美国版/Food Labeling Management App UniApp/src/locales/en.ts
1 export default { 1 export default {
2 Home: 'Home', 2 Home: 'Home',
3 Labeling: 'Labeling', 3 Labeling: 'Labeling',
4 - categories: {  
5 - menuGroup: 'Categories',  
6 - productCategories: 'Product categories',  
7 - labelCategories: 'Label categories',  
8 - productTitle: 'Product categories',  
9 - labelTitle: 'Label categories',  
10 - searchPlaceholder: 'Search by name or code…',  
11 - loading: 'Loading…',  
12 - empty: 'No categories found',  
13 - loadFailed: 'Failed to load',  
14 - active: 'Active',  
15 - inactive: 'Inactive',  
16 - pullMore: 'Scroll for more',  
17 - },  
18 common: { back: 'Back', confirm: 'Confirm', cancel: 'Cancel', online: 'Online', loading: 'Loading…' }, 4 common: { back: 'Back', confirm: 'Confirm', cancel: 'Cancel', online: 'Online', loading: 'Loading…' },
19 login: { 5 login: {
20 appName: 'Food Label System', 6 appName: 'Food Label System',
美国版/Food Labeling Management App UniApp/src/locales/zh.ts
1 export default { 1 export default {
2 Home: '首页', 2 Home: '首页',
3 Labeling: '标签', 3 Labeling: '标签',
4 - categories: {  
5 - menuGroup: '分类目录',  
6 - productCategories: '产品分类',  
7 - labelCategories: '标签分类',  
8 - productTitle: '产品分类',  
9 - labelTitle: '标签分类',  
10 - searchPlaceholder: '按名称或编码搜索…',  
11 - loading: '加载中…',  
12 - empty: '暂无分类',  
13 - loadFailed: '加载失败',  
14 - active: '启用',  
15 - inactive: '停用',  
16 - pullMore: '上拉加载更多',  
17 - },  
18 common: { back: '返回', confirm: '确认', cancel: '取消', online: '在线', loading: '加载中…' }, 4 common: { back: '返回', confirm: '确认', cancel: '取消', online: '在线', loading: '加载中…' },
19 login: { 5 login: {
20 appName: '食品标签系统', 6 appName: '食品标签系统',
美国版/Food Labeling Management App UniApp/src/pages.json
@@ -29,20 +29,6 @@ @@ -29,20 +29,6 @@
29 } 29 }
30 }, 30 },
31 { 31 {
32 - "path": "pages/categories/product-categories",  
33 - "style": {  
34 - "navigationBarTitleText": "Product Categories",  
35 - "navigationStyle": "custom"  
36 - }  
37 - },  
38 - {  
39 - "path": "pages/categories/label-categories",  
40 - "style": {  
41 - "navigationBarTitleText": "Label Categories",  
42 - "navigationStyle": "custom"  
43 - }  
44 - },  
45 - {  
46 "path": "pages/labels/food-select", 32 "path": "pages/labels/food-select",
47 "style": { 33 "style": {
48 "navigationBarTitleText": "Select Food", 34 "navigationBarTitleText": "Select Food",
美国版/Food Labeling Management App UniApp/src/pages/categories/label-categories.vue deleted
1 -<template>  
2 - <view class="page">  
3 - <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }">  
4 - <view class="top-bar">  
5 - <view class="top-left" @click="goBack">  
6 - <AppIcon name="chevronLeft" size="sm" color="white" />  
7 - </view>  
8 - <view class="top-center">  
9 - <text class="title">{{ t('categories.labelTitle') }}</text>  
10 - </view>  
11 - <view class="top-right" />  
12 - </view>  
13 - </view>  
14 -  
15 - <view class="search-box">  
16 - <view class="search-icon-wrap">  
17 - <AppIcon name="search" size="sm" color="gray" />  
18 - </view>  
19 - <input  
20 - v-model="searchInput"  
21 - class="search-input"  
22 - :placeholder="t('categories.searchPlaceholder')"  
23 - placeholder-class="placeholder"  
24 - />  
25 - </view>  
26 -  
27 - <scroll-view class="list-wrap" scroll-y @scrolltolower="loadMore">  
28 - <view v-if="loading && items.length === 0" class="state">  
29 - <text class="state-text">{{ t('categories.loading') }}</text>  
30 - </view>  
31 - <view v-else-if="errorText" class="state">  
32 - <text class="state-text">{{ errorText }}</text>  
33 - </view>  
34 - <view v-else-if="items.length === 0" class="state">  
35 - <text class="state-text">{{ t('categories.empty') }}</text>  
36 - </view>  
37 - <view v-else class="cards">  
38 - <view v-for="row in items" :key="row.id" class="card">  
39 - <view class="card-icon" :style="cardIconBoxStyle(row)">  
40 - <image  
41 - v-if="rowVisual(row).mode === 'image'"  
42 - :src="photoUrl(rowVisual(row).imageUrl) || ''"  
43 - class="card-icon-img"  
44 - mode="aspectFill"  
45 - />  
46 - <view v-else-if="rowVisual(row).mode === 'colorText'" class="card-icon-text card-icon-text--on-color">  
47 - <text  
48 - class="card-icon-text-inner"  
49 - :style="{ color: rowVisual(row).textColor || '#ffffff' }"  
50 - >{{ rowVisual(row).text }}</text>  
51 - </view>  
52 - <view  
53 - v-else-if="rowVisual(row).mode === 'color'"  
54 - class="card-icon-color"  
55 - :style="{ backgroundColor: rowVisual(row).bg }"  
56 - />  
57 - <view v-else-if="rowVisual(row).mode === 'text'" class="card-icon-text">  
58 - <text class="card-icon-text-inner">{{ rowVisual(row).text }}</text>  
59 - </view>  
60 - <view v-else class="card-icon-ph">  
61 - <AppIcon name="tag" size="md" color="gray" />  
62 - </view>  
63 - </view>  
64 - <view class="card-body">  
65 - <text class="card-name">{{ row.categoryName }}</text>  
66 - <text class="card-code">{{ row.categoryCode }}</text>  
67 - <view class="card-meta">  
68 - <text class="badge" :class="row.state ? 'on' : 'off'">  
69 - {{ row.state ? t('categories.active') : t('categories.inactive') }}  
70 - </text>  
71 - <text v-if="row.lastEdited" class="edited">{{ row.lastEdited }}</text>  
72 - </view>  
73 - </view>  
74 - </view>  
75 - </view>  
76 - <view v-if="loading && items.length > 0" class="footer-loading">  
77 - <text class="state-text">{{ t('categories.loading') }}</text>  
78 - </view>  
79 - <view v-else-if="hasMorePage && items.length > 0" class="footer-more">  
80 - <text class="hint">{{ t('categories.pullMore') }}</text>  
81 - </view>  
82 - </scroll-view>  
83 - </view>  
84 -</template>  
85 -  
86 -<script setup lang="ts">  
87 -import { ref, watch, computed } from 'vue'  
88 -import { onShow } from '@dcloudio/uni-app'  
89 -import { useI18n } from 'vue-i18n'  
90 -import AppIcon from '../../components/AppIcon.vue'  
91 -import { getStatusBarHeight } from '../../utils/statusBar'  
92 -import { getAccessToken } from '../../utils/authSession'  
93 -import { fetchLabelCategoryPage } from '../../services/labelCategory'  
94 -import type { LabelCategoryListItemDto } from '../../types/platformCategories'  
95 -import { resolveMediaUrlForApp } from '../../utils/resolveMediaUrl'  
96 -import { resolveCategoryButtonVisualFromDto, type CategoryVisualRender } from '../../utils/categoryButtonAppearance'  
97 -import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'  
98 -  
99 -const { t } = useI18n()  
100 -const statusBarHeight = getStatusBarHeight()  
101 -const PAGE = 50  
102 -  
103 -const searchInput = ref('')  
104 -const debouncedKeyword = ref('')  
105 -const items = ref<LabelCategoryListItemDto[]>([])  
106 -const totalCount = ref(0)  
107 -const loading = ref(false)  
108 -const errorText = ref('')  
109 -let searchTimer: ReturnType<typeof setTimeout> | null = null  
110 -  
111 -const hasMorePage = computed(() => items.value.length < totalCount.value)  
112 -const nextApiPage = ref(1)  
113 -  
114 -watch(searchInput, () => {  
115 - if (searchTimer) clearTimeout(searchTimer)  
116 - searchTimer = setTimeout(() => {  
117 - debouncedKeyword.value = searchInput.value.trim()  
118 - }, 350)  
119 -})  
120 -  
121 -watch(debouncedKeyword, () => {  
122 - resetAndLoad()  
123 -})  
124 -  
125 -onShow(() => {  
126 - if (!getAccessToken()) {  
127 - uni.reLaunch({ url: '/pages/login/login' })  
128 - return  
129 - }  
130 - resetAndLoad()  
131 -})  
132 -  
133 -function photoUrl(u: string | null | undefined) {  
134 - return resolveMediaUrlForApp(u)  
135 -}  
136 -  
137 -function rowVisual(row: LabelCategoryListItemDto): CategoryVisualRender {  
138 - return resolveCategoryButtonVisualFromDto(row)  
139 -}  
140 -  
141 -function cardIconBoxStyle(row: LabelCategoryListItemDto): Record<string, string> {  
142 - const v = rowVisual(row)  
143 - if (v.mode === 'colorText') return { backgroundColor: v.bg }  
144 - return {}  
145 -}  
146 -  
147 -function goBack() {  
148 - const pages = getCurrentPages()  
149 - if (pages.length > 1) uni.navigateBack()  
150 - else uni.redirectTo({ url: '/pages/index/index' })  
151 -}  
152 -  
153 -async function resetAndLoad() {  
154 - items.value = []  
155 - totalCount.value = 0  
156 - nextApiPage.value = 1  
157 - await fetchPage(true)  
158 -}  
159 -  
160 -async function fetchPage(replace: boolean) {  
161 - loading.value = true  
162 - errorText.value = ''  
163 - const page = nextApiPage.value  
164 - try {  
165 - const { items: rows, totalCount: tc } = await fetchLabelCategoryPage({  
166 - skipCount: page,  
167 - maxResultCount: PAGE,  
168 - keyword: debouncedKeyword.value || undefined,  
169 - state: true,  
170 - })  
171 - totalCount.value = tc  
172 - if (replace) items.value = rows  
173 - else items.value = items.value.concat(rows)  
174 - nextApiPage.value = page + 1  
175 - } catch (e: unknown) {  
176 - if (isUsAppSessionExpiredError(e)) return  
177 - errorText.value = e instanceof Error ? e.message : t('categories.loadFailed')  
178 - if (replace) items.value = []  
179 - } finally {  
180 - loading.value = false  
181 - }  
182 -}  
183 -  
184 -async function loadMore() {  
185 - if (loading.value || !hasMorePage.value) return  
186 - await fetchPage(false)  
187 -}  
188 -</script>  
189 -  
190 -<style scoped>  
191 -.page {  
192 - min-height: 100vh;  
193 - background: #f9fafb;  
194 - display: flex;  
195 - flex-direction: column;  
196 -}  
197 -.header-hero {  
198 - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-dark));  
199 - padding: 16rpx 32rpx 24rpx;  
200 -}  
201 -.top-bar {  
202 - height: 88rpx;  
203 - display: flex;  
204 - align-items: center;  
205 - justify-content: space-between;  
206 -}  
207 -.top-left,  
208 -.top-right {  
209 - width: 64rpx;  
210 - height: 64rpx;  
211 - border-radius: 999rpx;  
212 - background: rgba(255, 255, 255, 0.15);  
213 - display: flex;  
214 - align-items: center;  
215 - justify-content: center;  
216 -}  
217 -.top-center {  
218 - flex: 1;  
219 - text-align: center;  
220 -}  
221 -.title {  
222 - font-size: 32rpx;  
223 - font-weight: 600;  
224 - color: #fff;  
225 -}  
226 -.search-box {  
227 - position: relative;  
228 - padding: 20rpx 24rpx;  
229 - background: #fff;  
230 - border-bottom: 1rpx solid #e5e7eb;  
231 -}  
232 -.search-icon-wrap {  
233 - position: absolute;  
234 - left: 44rpx;  
235 - top: 50%;  
236 - transform: translateY(-50%);  
237 - z-index: 1;  
238 -}  
239 -.search-input {  
240 - height: 72rpx;  
241 - padding-left: 64rpx;  
242 - padding-right: 20rpx;  
243 - background: #f3f4f6;  
244 - border-radius: 16rpx;  
245 - font-size: 26rpx;  
246 -}  
247 -.list-wrap {  
248 - flex: 1;  
249 - height: 0;  
250 - min-height: 400rpx;  
251 -}  
252 -.state {  
253 - padding: 80rpx 32rpx;  
254 - text-align: center;  
255 -}  
256 -.state-text {  
257 - font-size: 28rpx;  
258 - color: #9ca3af;  
259 -}  
260 -.cards {  
261 - padding: 16rpx 24rpx 48rpx;  
262 - display: flex;  
263 - flex-direction: column;  
264 - gap: 16rpx;  
265 -}  
266 -.card {  
267 - display: flex;  
268 - flex-direction: row;  
269 - align-items: center;  
270 - gap: 20rpx;  
271 - background: #fff;  
272 - border-radius: 16rpx;  
273 - padding: 20rpx;  
274 - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);  
275 -}  
276 -.card-icon {  
277 - width: 96rpx;  
278 - height: 96rpx;  
279 - border-radius: 12rpx;  
280 - flex-shrink: 0;  
281 - overflow: hidden;  
282 - background: #f3f4f6;  
283 - display: flex;  
284 - align-items: center;  
285 - justify-content: center;  
286 -}  
287 -.card-icon-img {  
288 - width: 100%;  
289 - height: 100%;  
290 -}  
291 -.card-icon-color {  
292 - width: 100%;  
293 - height: 100%;  
294 -}  
295 -.card-icon-text {  
296 - width: 100%;  
297 - height: 100%;  
298 - display: flex;  
299 - align-items: center;  
300 - justify-content: center;  
301 - padding: 8rpx;  
302 -}  
303 -.card-icon-text-inner {  
304 - max-width: 100%;  
305 - font-size: 24rpx;  
306 - font-weight: 700;  
307 - color: #111827;  
308 - overflow: hidden;  
309 - text-overflow: ellipsis;  
310 - white-space: nowrap;  
311 -}  
312 -.card-icon-text--on-color .card-icon-text-inner {  
313 - white-space: normal;  
314 - text-align: center;  
315 - line-height: 1.2;  
316 - display: -webkit-box;  
317 - -webkit-line-clamp: 2;  
318 - -webkit-box-orient: vertical;  
319 - overflow: hidden;  
320 -}  
321 -.card-icon-ph {  
322 - width: 100%;  
323 - height: 100%;  
324 - display: flex;  
325 - align-items: center;  
326 - justify-content: center;  
327 -}  
328 -.card-body {  
329 - flex: 1;  
330 - min-width: 0;  
331 -}  
332 -.card-name {  
333 - font-size: 30rpx;  
334 - font-weight: 600;  
335 - color: #111827;  
336 - display: block;  
337 -}  
338 -.card-code {  
339 - font-size: 24rpx;  
340 - color: #6b7280;  
341 - display: block;  
342 - margin-top: 6rpx;  
343 -}  
344 -.card-meta {  
345 - display: flex;  
346 - flex-wrap: wrap;  
347 - align-items: center;  
348 - gap: 12rpx;  
349 - margin-top: 10rpx;  
350 -}  
351 -.badge {  
352 - font-size: 22rpx;  
353 - padding: 4rpx 12rpx;  
354 - border-radius: 8rpx;  
355 -}  
356 -.badge.on {  
357 - background: #dcfce7;  
358 - color: #166534;  
359 -}  
360 -.badge.off {  
361 - background: #fee2e2;  
362 - color: #991b1b;  
363 -}  
364 -.edited {  
365 - font-size: 22rpx;  
366 - color: #9ca3af;  
367 -}  
368 -.footer-loading,  
369 -.footer-more {  
370 - padding: 24rpx;  
371 - text-align: center;  
372 -}  
373 -.hint {  
374 - font-size: 24rpx;  
375 - color: #9ca3af;  
376 -}  
377 -</style>  
美国版/Food Labeling Management App UniApp/src/pages/categories/product-categories.vue deleted
1 -<template>  
2 - <view class="page">  
3 - <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }">  
4 - <view class="top-bar">  
5 - <view class="top-left" @click="goBack">  
6 - <AppIcon name="chevronLeft" size="sm" color="white" />  
7 - </view>  
8 - <view class="top-center">  
9 - <text class="title">{{ t('categories.productTitle') }}</text>  
10 - </view>  
11 - <view class="top-right" />  
12 - </view>  
13 - </view>  
14 -  
15 - <view class="search-box">  
16 - <view class="search-icon-wrap">  
17 - <AppIcon name="search" size="sm" color="gray" />  
18 - </view>  
19 - <input  
20 - v-model="searchInput"  
21 - class="search-input"  
22 - :placeholder="t('categories.searchPlaceholder')"  
23 - placeholder-class="placeholder"  
24 - />  
25 - </view>  
26 -  
27 - <scroll-view class="list-wrap" scroll-y @scrolltolower="loadMore">  
28 - <view v-if="loading && items.length === 0" class="state">  
29 - <text class="state-text">{{ t('categories.loading') }}</text>  
30 - </view>  
31 - <view v-else-if="errorText" class="state">  
32 - <text class="state-text">{{ errorText }}</text>  
33 - </view>  
34 - <view v-else-if="items.length === 0" class="state">  
35 - <text class="state-text">{{ t('categories.empty') }}</text>  
36 - </view>  
37 - <view v-else class="cards">  
38 - <view v-for="row in items" :key="row.id" class="card">  
39 - <view class="card-icon" :style="cardIconBoxStyle(row)">  
40 - <image  
41 - v-if="rowVisual(row).mode === 'image'"  
42 - :src="photoUrl(rowVisual(row).imageUrl) || ''"  
43 - class="card-icon-img"  
44 - mode="aspectFill"  
45 - />  
46 - <view v-else-if="rowVisual(row).mode === 'colorText'" class="card-icon-text card-icon-text--on-color">  
47 - <text  
48 - class="card-icon-text-inner"  
49 - :style="{ color: rowVisual(row).textColor || '#ffffff' }"  
50 - >{{ rowVisual(row).text }}</text>  
51 - </view>  
52 - <view  
53 - v-else-if="rowVisual(row).mode === 'color'"  
54 - class="card-icon-color"  
55 - :style="{ backgroundColor: rowVisual(row).bg }"  
56 - />  
57 - <view v-else-if="rowVisual(row).mode === 'text'" class="card-icon-text">  
58 - <text class="card-icon-text-inner">{{ rowVisual(row).text }}</text>  
59 - </view>  
60 - <view v-else class="card-icon-ph">  
61 - <AppIcon name="food" size="md" color="gray" />  
62 - </view>  
63 - </view>  
64 - <view class="card-body">  
65 - <text class="card-name">{{ row.categoryName }}</text>  
66 - <text class="card-code">{{ row.categoryCode }}</text>  
67 - <view class="card-meta">  
68 - <text class="badge" :class="row.state ? 'on' : 'off'">  
69 - {{ row.state ? t('categories.active') : t('categories.inactive') }}  
70 - </text>  
71 - <text v-if="row.lastEdited" class="edited">{{ row.lastEdited }}</text>  
72 - </view>  
73 - </view>  
74 - </view>  
75 - </view>  
76 - <view v-if="loading && items.length > 0" class="footer-loading">  
77 - <text class="state-text">{{ t('categories.loading') }}</text>  
78 - </view>  
79 - <view v-else-if="hasMorePage && items.length > 0" class="footer-more">  
80 - <text class="hint">{{ t('categories.pullMore') }}</text>  
81 - </view>  
82 - </scroll-view>  
83 - </view>  
84 -</template>  
85 -  
86 -<script setup lang="ts">  
87 -import { ref, watch, computed } from 'vue'  
88 -import { onShow } from '@dcloudio/uni-app'  
89 -import { useI18n } from 'vue-i18n'  
90 -import AppIcon from '../../components/AppIcon.vue'  
91 -import { getStatusBarHeight } from '../../utils/statusBar'  
92 -import { getAccessToken } from '../../utils/authSession'  
93 -import { fetchProductCategoryPage } from '../../services/productCategory'  
94 -import type { ProductCategoryListItemDto } from '../../types/platformCategories'  
95 -import { resolveMediaUrlForApp } from '../../utils/resolveMediaUrl'  
96 -import { resolveCategoryButtonVisualFromDto, type CategoryVisualRender } from '../../utils/categoryButtonAppearance'  
97 -import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'  
98 -  
99 -const { t } = useI18n()  
100 -const statusBarHeight = getStatusBarHeight()  
101 -const PAGE = 50  
102 -  
103 -const searchInput = ref('')  
104 -const debouncedKeyword = ref('')  
105 -const items = ref<ProductCategoryListItemDto[]>([])  
106 -const totalCount = ref(0)  
107 -const loading = ref(false)  
108 -const errorText = ref('')  
109 -let searchTimer: ReturnType<typeof setTimeout> | null = null  
110 -  
111 -const hasMorePage = computed(() => items.value.length < totalCount.value)  
112 -/** 下一请求页码(SkipCount,从 1 起) */  
113 -const nextApiPage = ref(1)  
114 -  
115 -watch(searchInput, () => {  
116 - if (searchTimer) clearTimeout(searchTimer)  
117 - searchTimer = setTimeout(() => {  
118 - debouncedKeyword.value = searchInput.value.trim()  
119 - }, 350)  
120 -})  
121 -  
122 -watch(debouncedKeyword, () => {  
123 - resetAndLoad()  
124 -})  
125 -  
126 -onShow(() => {  
127 - if (!getAccessToken()) {  
128 - uni.reLaunch({ url: '/pages/login/login' })  
129 - return  
130 - }  
131 - resetAndLoad()  
132 -})  
133 -  
134 -function photoUrl(u: string | null | undefined) {  
135 - return resolveMediaUrlForApp(u)  
136 -}  
137 -  
138 -function rowVisual(row: ProductCategoryListItemDto): CategoryVisualRender {  
139 - return resolveCategoryButtonVisualFromDto(row)  
140 -}  
141 -  
142 -function cardIconBoxStyle(row: ProductCategoryListItemDto): Record<string, string> {  
143 - const v = rowVisual(row)  
144 - if (v.mode === 'colorText') return { backgroundColor: v.bg }  
145 - return {}  
146 -}  
147 -  
148 -function goBack() {  
149 - const pages = getCurrentPages()  
150 - if (pages.length > 1) uni.navigateBack()  
151 - else uni.redirectTo({ url: '/pages/index/index' })  
152 -}  
153 -  
154 -async function resetAndLoad() {  
155 - items.value = []  
156 - totalCount.value = 0  
157 - nextApiPage.value = 1  
158 - await fetchPage(true)  
159 -}  
160 -  
161 -async function fetchPage(replace: boolean) {  
162 - loading.value = true  
163 - errorText.value = ''  
164 - const page = nextApiPage.value  
165 - try {  
166 - const { items: rows, totalCount: tc } = await fetchProductCategoryPage({  
167 - skipCount: page,  
168 - maxResultCount: PAGE,  
169 - keyword: debouncedKeyword.value || undefined,  
170 - state: true,  
171 - })  
172 - totalCount.value = tc  
173 - if (replace) items.value = rows  
174 - else items.value = items.value.concat(rows)  
175 - nextApiPage.value = page + 1  
176 - } catch (e: unknown) {  
177 - if (isUsAppSessionExpiredError(e)) return  
178 - errorText.value = e instanceof Error ? e.message : t('categories.loadFailed')  
179 - if (replace) items.value = []  
180 - } finally {  
181 - loading.value = false  
182 - }  
183 -}  
184 -  
185 -async function loadMore() {  
186 - if (loading.value || !hasMorePage.value) return  
187 - await fetchPage(false)  
188 -}  
189 -</script>  
190 -  
191 -<style scoped>  
192 -.page {  
193 - min-height: 100vh;  
194 - background: #f9fafb;  
195 - display: flex;  
196 - flex-direction: column;  
197 -}  
198 -.header-hero {  
199 - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-dark));  
200 - padding: 16rpx 32rpx 24rpx;  
201 -}  
202 -.top-bar {  
203 - height: 88rpx;  
204 - display: flex;  
205 - align-items: center;  
206 - justify-content: space-between;  
207 -}  
208 -.top-left,  
209 -.top-right {  
210 - width: 64rpx;  
211 - height: 64rpx;  
212 - border-radius: 999rpx;  
213 - background: rgba(255, 255, 255, 0.15);  
214 - display: flex;  
215 - align-items: center;  
216 - justify-content: center;  
217 -}  
218 -.top-center {  
219 - flex: 1;  
220 - text-align: center;  
221 -}  
222 -.title {  
223 - font-size: 32rpx;  
224 - font-weight: 600;  
225 - color: #fff;  
226 -}  
227 -.search-box {  
228 - position: relative;  
229 - padding: 20rpx 24rpx;  
230 - background: #fff;  
231 - border-bottom: 1rpx solid #e5e7eb;  
232 -}  
233 -.search-icon-wrap {  
234 - position: absolute;  
235 - left: 44rpx;  
236 - top: 50%;  
237 - transform: translateY(-50%);  
238 - z-index: 1;  
239 -}  
240 -.search-input {  
241 - height: 72rpx;  
242 - padding-left: 64rpx;  
243 - padding-right: 20rpx;  
244 - background: #f3f4f6;  
245 - border-radius: 16rpx;  
246 - font-size: 26rpx;  
247 -}  
248 -.list-wrap {  
249 - flex: 1;  
250 - height: 0;  
251 - min-height: 400rpx;  
252 -}  
253 -.state {  
254 - padding: 80rpx 32rpx;  
255 - text-align: center;  
256 -}  
257 -.state-text {  
258 - font-size: 28rpx;  
259 - color: #9ca3af;  
260 -}  
261 -.cards {  
262 - padding: 16rpx 24rpx 48rpx;  
263 - display: flex;  
264 - flex-direction: column;  
265 - gap: 16rpx;  
266 -}  
267 -.card {  
268 - display: flex;  
269 - flex-direction: row;  
270 - align-items: center;  
271 - gap: 20rpx;  
272 - background: #fff;  
273 - border-radius: 16rpx;  
274 - padding: 20rpx;  
275 - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);  
276 -}  
277 -.card-icon {  
278 - width: 96rpx;  
279 - height: 96rpx;  
280 - border-radius: 12rpx;  
281 - flex-shrink: 0;  
282 - overflow: hidden;  
283 - background: #f3f4f6;  
284 - display: flex;  
285 - align-items: center;  
286 - justify-content: center;  
287 -}  
288 -.card-icon-img {  
289 - width: 100%;  
290 - height: 100%;  
291 -}  
292 -.card-icon-color {  
293 - width: 100%;  
294 - height: 100%;  
295 -}  
296 -.card-icon-text {  
297 - width: 100%;  
298 - height: 100%;  
299 - display: flex;  
300 - align-items: center;  
301 - justify-content: center;  
302 - padding: 8rpx;  
303 -}  
304 -.card-icon-text-inner {  
305 - max-width: 100%;  
306 - font-size: 24rpx;  
307 - font-weight: 700;  
308 - color: #111827;  
309 - overflow: hidden;  
310 - text-overflow: ellipsis;  
311 - white-space: nowrap;  
312 -}  
313 -.card-icon-text--on-color .card-icon-text-inner {  
314 - white-space: normal;  
315 - text-align: center;  
316 - line-height: 1.2;  
317 - display: -webkit-box;  
318 - -webkit-line-clamp: 2;  
319 - -webkit-box-orient: vertical;  
320 - overflow: hidden;  
321 -}  
322 -.card-icon-ph {  
323 - width: 100%;  
324 - height: 100%;  
325 - display: flex;  
326 - align-items: center;  
327 - justify-content: center;  
328 -}  
329 -.card-body {  
330 - flex: 1;  
331 - min-width: 0;  
332 -}  
333 -.card-name {  
334 - font-size: 30rpx;  
335 - font-weight: 600;  
336 - color: #111827;  
337 - display: block;  
338 -}  
339 -.card-code {  
340 - font-size: 24rpx;  
341 - color: #6b7280;  
342 - display: block;  
343 - margin-top: 6rpx;  
344 -}  
345 -.card-meta {  
346 - display: flex;  
347 - flex-wrap: wrap;  
348 - align-items: center;  
349 - gap: 12rpx;  
350 - margin-top: 10rpx;  
351 -}  
352 -.badge {  
353 - font-size: 22rpx;  
354 - padding: 4rpx 12rpx;  
355 - border-radius: 8rpx;  
356 -}  
357 -.badge.on {  
358 - background: #dcfce7;  
359 - color: #166534;  
360 -}  
361 -.badge.off {  
362 - background: #fee2e2;  
363 - color: #991b1b;  
364 -}  
365 -.edited {  
366 - font-size: 22rpx;  
367 - color: #9ca3af;  
368 -}  
369 -.footer-loading,  
370 -.footer-more {  
371 - padding: 24rpx;  
372 - text-align: center;  
373 -}  
374 -.hint {  
375 - font-size: 24rpx;  
376 - color: #9ca3af;  
377 -}  
378 -</style>  
美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue
@@ -51,7 +51,16 @@ @@ -51,7 +51,16 @@
51 </view> 51 </view>
52 52
53 <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }"> 53 <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }">
  54 + <view v-if="!loading && stores.length === 0" class="bottom-actions-row">
  55 + <view class="back-btn" @click="handleBackToLogin">
  56 + <text class="back-btn-text">{{ t('common.back') }}</text>
  57 + </view>
  58 + <view class="confirm-btn disabled">
  59 + <text class="confirm-btn-text">{{ t('common.confirm') }}</text>
  60 + </view>
  61 + </view>
54 <view 62 <view
  63 + v-else
55 class="confirm-btn" 64 class="confirm-btn"
56 :class="{ disabled: !selectedStore || loading }" 65 :class="{ disabled: !selectedStore || loading }"
57 @click="handleConfirm" 66 @click="handleConfirm"
@@ -71,7 +80,7 @@ import { getStatusBarHeight, getBottomSafeArea } from &#39;../../utils/statusBar&#39; @@ -71,7 +80,7 @@ import { getStatusBarHeight, getBottomSafeArea } from &#39;../../utils/statusBar&#39;
71 import { usAppFetchMyLocations } from '../../services/usAppAuth' 80 import { usAppFetchMyLocations } from '../../services/usAppAuth'
72 import type { UsAppBoundLocationDto } from '../../types/usAppBound' 81 import type { UsAppBoundLocationDto } from '../../types/usAppBound'
73 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' 82 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
74 -import { setBoundLocations, getBoundLocations } from '../../utils/authSession' 83 +import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession'
75 import { switchStore } from '../../utils/stores' 84 import { switchStore } from '../../utils/stores'
76 85
77 const { t } = useI18n() 86 const { t } = useI18n()
@@ -111,6 +120,11 @@ onShow(() =&gt; { @@ -111,6 +120,11 @@ onShow(() =&gt; {
111 applyList(getBoundLocations()) 120 applyList(getBoundLocations())
112 }) 121 })
113 122
  123 +const handleBackToLogin = () => {
  124 + clearAuthSession()
  125 + uni.redirectTo({ url: '/pages/login/login' })
  126 +}
  127 +
114 const handleConfirm = () => { 128 const handleConfirm = () => {
115 if (loading.value || !selectedStore.value) { 129 if (loading.value || !selectedStore.value) {
116 if (!selectedStore.value) { 130 if (!selectedStore.value) {
@@ -277,6 +291,35 @@ const handleConfirm = () =&gt; { @@ -277,6 +291,35 @@ const handleConfirm = () =&gt; {
277 border-top: 1rpx solid #e5e7eb; 291 border-top: 1rpx solid #e5e7eb;
278 } 292 }
279 293
  294 +.bottom-actions-row {
  295 + display: flex;
  296 + align-items: stretch;
  297 + gap: 24rpx;
  298 +}
  299 +
  300 +.back-btn {
  301 + flex: 1;
  302 + height: 96rpx;
  303 + border-radius: 16rpx;
  304 + display: flex;
  305 + align-items: center;
  306 + justify-content: center;
  307 + border: 3rpx solid var(--theme-primary);
  308 + background: #ffffff;
  309 + box-sizing: border-box;
  310 +}
  311 +
  312 +.back-btn-text {
  313 + font-size: 32rpx;
  314 + font-weight: 600;
  315 + color: var(--theme-primary);
  316 + line-height: 1;
  317 +}
  318 +
  319 +.bottom-actions-row .confirm-btn {
  320 + flex: 1;
  321 +}
  322 +
280 .confirm-btn { 323 .confirm-btn {
281 width: 100%; 324 width: 100%;
282 height: 96rpx; 325 height: 96rpx;
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
1 import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' 1 import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
2 import { resolveTemplateDefaultValueForElement } from './printInputOffset' 2 import { resolveTemplateDefaultValueForElement } from './printInputOffset'
  3 +import { applyNutritionDefaultJsonToConfig } from './nutritionDefaultsMerge'
3 4
4 function asRecord(v: unknown): Record<string, unknown> { 5 function asRecord(v: unknown): Record<string, unknown> {
5 if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown> 6 if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown>
@@ -285,6 +286,17 @@ export function applyTemplateProductDefaultValuesToTemplate( @@ -285,6 +286,17 @@ export function applyTemplateProductDefaultValuesToTemplate(
285 return { ...el, config: cfg } 286 return { ...el, config: cfg }
286 } 287 }
287 288
  289 + if (type === 'NUTRITION') {
  290 + const s = String(v).trim()
  291 + if (s.startsWith('{')) {
  292 + const merged = applyNutritionDefaultJsonToConfig(cfg, s)
  293 + return { ...el, config: merged }
  294 + }
  295 + cfg.text = s
  296 + cfg.Text = s
  297 + return { ...el, config: cfg }
  298 + }
  299 +
288 cfg.text = v 300 cfg.text = v
289 cfg.Text = v 301 cfg.Text = v
290 return { ...el, config: cfg } 302 return { ...el, config: cfg }
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/nutritionDefaultsMerge.ts 0 → 100644
  1 +/**
  2 + * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。
  3 + */
  4 +export function applyNutritionDefaultJsonToConfig(
  5 + baseCfg: Record<string, unknown>,
  6 + jsonStr: string,
  7 +): Record<string, unknown> {
  8 + const t = String(jsonStr ?? "").trim();
  9 + if (!t.startsWith("{")) return baseCfg;
  10 + let manual: Record<string, string> = {};
  11 + try {
  12 + manual = JSON.parse(t) as Record<string, string>;
  13 + } catch {
  14 + return baseCfg;
  15 + }
  16 + const out: Record<string, unknown> = { ...baseCfg };
  17 + for (const [k, val] of Object.entries(manual)) {
  18 + const v = String(val ?? "").trim();
  19 + if (k === "calories") {
  20 + if (v) out.calories = v;
  21 + continue;
  22 + }
  23 + if (k === "servingsPerContainer") {
  24 + out.servingsPerContainer = v;
  25 + continue;
  26 + }
  27 + if (k === "servingSize") {
  28 + out.servingSize = v;
  29 + continue;
  30 + }
  31 + if (k.startsWith("extra:") && k.endsWith(":value")) {
  32 + const id = k.slice("extra:".length, -":value".length);
  33 + const arr = Array.isArray(out.extraNutrients)
  34 + ? ([...(out.extraNutrients as Record<string, unknown>[])])
  35 + : [];
  36 + const idx = arr.findIndex((row) => String((row as any).id ?? "") === id);
  37 + if (idx >= 0) {
  38 + arr[idx] = { ...arr[idx], value: v };
  39 + }
  40 + out.extraNutrients = arr;
  41 + continue;
  42 + }
  43 + const fr = Array.isArray(out.fixedNutrients)
  44 + ? ([...(out.fixedNutrients as Record<string, unknown>[])])
  45 + : [];
  46 + const idx = fr.findIndex((row) => String((row as any).key ?? "").trim() === k);
  47 + if (idx >= 0) {
  48 + fr[idx] = { ...fr[idx], value: v };
  49 + } else {
  50 + fr.push({ key: k, label: k, value: v, unit: "" });
  51 + }
  52 + out.fixedNutrients = fr;
  53 + }
  54 + return out;
  55 +}
美国版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.AspNetCore/Mvc/YiConventionalRouteBuilder.cs
1 -using JetBrains.Annotations; 1 +using JetBrains.Annotations;
2 using Microsoft.AspNetCore.Mvc.ApplicationModels; 2 using Microsoft.AspNetCore.Mvc.ApplicationModels;
3 using System.Reflection; 3 using System.Reflection;
4 using Microsoft.Extensions.DependencyInjection; 4 using Microsoft.Extensions.DependencyInjection;
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application.Contracts/IServices/IRoleService.cs
@@ -23,5 +23,12 @@ namespace Yi.Framework.Rbac.Application.Contracts.IServices @@ -23,5 +23,12 @@ namespace Yi.Framework.Rbac.Application.Contracts.IServices
23 /// <param name="roleId">角色ID</param> 23 /// <param name="roleId">角色ID</param>
24 /// <returns>角色部门树数据,包含已选中的部门ID和部门树结构</returns> 24 /// <returns>角色部门树数据,包含已选中的部门ID和部门树结构</returns>
25 Task<ActionResult> GetDeptTreeAsync(Guid roleId); 25 Task<ActionResult> GetDeptTreeAsync(Guid roleId);
  26 +
  27 + /// <summary>
  28 + /// 按与列表相同的筛选条件导出角色为 PDF(不分页,上限 5000 条)
  29 + /// </summary>
  30 + /// <param name="input">RoleName、RoleCode、State;分页字段忽略</param>
  31 + /// <returns>PDF 文件流</returns>
  32 + Task<IActionResult> ExportPdfAsync(RoleGetListInputVo input);
26 } 33 }
27 } 34 }
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Services/System/RoleService.cs
1 using Mapster; 1 using Mapster;
2 using Microsoft.AspNetCore.Mvc; 2 using Microsoft.AspNetCore.Mvc;
  3 +using QuestPDF.Fluent;
  4 +using QuestPDF.Helpers;
  5 +using QuestPDF.Infrastructure;
3 using SqlSugar; 6 using SqlSugar;
  7 +using System.IO;
  8 +using Volo.Abp;
4 using Volo.Abp.Application.Dtos; 9 using Volo.Abp.Application.Dtos;
5 using Volo.Abp.Application.Services; 10 using Volo.Abp.Application.Services;
6 using Volo.Abp.Domain.Entities; 11 using Volo.Abp.Domain.Entities;
@@ -73,6 +78,77 @@ namespace Yi.Framework.Rbac.Application.Services.System @@ -73,6 +78,77 @@ namespace Yi.Framework.Rbac.Application.Services.System
73 return new PagedResultDto<RoleGetListOutputDto>(total, await MapToGetListOutputDtosAsync(entities)); 78 return new PagedResultDto<RoleGetListOutputDto>(total, await MapToGetListOutputDtosAsync(entities));
74 } 79 }
75 80
  81 + /// <inheritdoc />
  82 + public async Task<IActionResult> ExportPdfAsync([FromQuery] RoleGetListInputVo input)
  83 + {
  84 + QuestPDF.Settings.License = LicenseType.Community;
  85 + const int exportPdfMaxRows = 5000;
  86 +
  87 + var query = BuildRoleListExportQuery(input);
  88 + var count = await query.CountAsync();
  89 + if (count > exportPdfMaxRows)
  90 + {
  91 + throw new UserFriendlyException($"导出数据超过上限 {exportPdfMaxRows} 条,请缩小筛选范围");
  92 + }
  93 +
  94 + var rows = await query.OrderBy(x => x.OrderNum, OrderByType.Desc).Take(exportPdfMaxRows).ToListAsync();
  95 +
  96 + var fileName = $"roles_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
  97 +
  98 + var document = Document.Create(container =>
  99 + {
  100 + container.Page(page =>
  101 + {
  102 + page.Margin(28);
  103 + page.DefaultTextStyle(x => x.FontSize(10));
  104 + page.Header().Text("Roles").SemiBold().FontSize(18);
  105 + page.Content().PaddingTop(12).Table(table =>
  106 + {
  107 + table.ColumnsDefinition(c =>
  108 + {
  109 + c.RelativeColumn(2f);
  110 + c.RelativeColumn(2f);
  111 + c.RelativeColumn(1f);
  112 + c.RelativeColumn(0.8f);
  113 + });
  114 +
  115 + static IContainer CellHeader(IContainer c) =>
  116 + c.Background(Colors.Grey.Lighten3).Padding(6).DefaultTextStyle(x => x.SemiBold());
  117 +
  118 + table.Cell().Element(CellHeader).Text("Role Name");
  119 + table.Cell().Element(CellHeader).Text("Role Code");
  120 + table.Cell().Element(CellHeader).Text("Status");
  121 + table.Cell().Element(CellHeader).Text("Order");
  122 +
  123 + foreach (var e in rows)
  124 + {
  125 + var status = e.State ? "active" : "inactive";
  126 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  127 + .Text(e.RoleName ?? string.Empty);
  128 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  129 + .Text(e.RoleCode ?? string.Empty);
  130 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(status);
  131 + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5)
  132 + .Text(e.OrderNum.ToString());
  133 + }
  134 + });
  135 + });
  136 + });
  137 +
  138 + var stream = new MemoryStream();
  139 + document.GeneratePdf(stream);
  140 + stream.Position = 0;
  141 + return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
  142 + }
  143 +
  144 + private ISugarQueryable<RoleAggregateRoot> BuildRoleListExportQuery(RoleGetListInputVo input)
  145 + {
  146 + return _repository._DbQueryable.WhereIF(!string.IsNullOrEmpty(input.RoleCode),
  147 + x => x.RoleCode.Contains(input.RoleCode!))
  148 + .WhereIF(!string.IsNullOrEmpty(input.RoleName), x => x.RoleName.Contains(input.RoleName!))
  149 + .WhereIF(input.State is not null, x => x.State == input.State);
  150 + }
  151 +
76 /// <summary> 152 /// <summary>
77 /// 添加角色 153 /// 添加角色
78 /// </summary> 154 /// </summary>
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Yi.Framework.Rbac.Application.csproj
@@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
9 9
10 <ItemGroup> 10 <ItemGroup>
11 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" /> 11 <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
  12 + <PackageReference Include="QuestPDF" Version="2024.12.2" />
12 <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" /> 13 <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" />
13 <PackageReference Include="Volo.Abp.BackgroundJobs.Hangfire" Version="$(AbpVersion)" /> 14 <PackageReference Include="Volo.Abp.BackgroundJobs.Hangfire" Version="$(AbpVersion)" />
14 15
美国版/Food Labeling Management Platform/build/assets/index-BHd3BZos.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-ChVLtgeV.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-DLL5VTnd.css 0 → 100644
  1 +.rdp{--rdp-cell-size: 40px;--rdp-caption-font-size: 18px;--rdp-accent-color: #0000ff;--rdp-background-color: #e7edff;--rdp-accent-color-dark: #3003e1;--rdp-background-color-dark: #180270;--rdp-outline: 2px solid var(--rdp-accent-color);--rdp-outline-selected: 3px solid var(--rdp-accent-color);--rdp-selected-color: #fff;margin:1em}.rdp-vhidden{box-sizing:border-box;padding:0;margin:0;background:transparent;border:0;-moz-appearance:none;-webkit-appearance:none;appearance:none;position:absolute!important;top:0;width:1px!important;height:1px!important;padding:0!important;overflow:hidden!important;clip:rect(1px,1px,1px,1px)!important;border:0!important}.rdp-button_reset{appearance:none;position:relative;margin:0;padding:0;cursor:default;color:inherit;background:none;font:inherit;-moz-appearance:none;-webkit-appearance:none}.rdp-button_reset:focus-visible{outline:none}.rdp-button{border:2px solid transparent}.rdp-button[disabled]:not(.rdp-day_selected){opacity:.25}.rdp-button:not([disabled]){cursor:pointer}.rdp-button:focus-visible:not([disabled]){color:inherit;background-color:var(--rdp-background-color);border:var(--rdp-outline)}.rdp-button:hover:not([disabled]):not(.rdp-day_selected){background-color:var(--rdp-background-color)}.rdp-months{display:flex}.rdp-month{margin:0 1em}.rdp-month:first-child{margin-left:0}.rdp-month:last-child{margin-right:0}.rdp-table{margin:0;max-width:calc(var(--rdp-cell-size) * 7);border-collapse:collapse}.rdp-with_weeknumber .rdp-table{max-width:calc(var(--rdp-cell-size) * 8);border-collapse:collapse}.rdp-caption{display:flex;align-items:center;justify-content:space-between;padding:0;text-align:left}.rdp-multiple_months .rdp-caption{position:relative;display:block;text-align:center}.rdp-caption_dropdowns{position:relative;display:inline-flex}.rdp-caption_label{position:relative;z-index:1;display:inline-flex;align-items:center;margin:0;padding:0 .25em;white-space:nowrap;color:currentColor;border:0;border:2px solid transparent;font-family:inherit;font-size:var(--rdp-caption-font-size);font-weight:700}.rdp-nav{white-space:nowrap}.rdp-multiple_months .rdp-caption_start .rdp-nav{position:absolute;top:50%;left:0;transform:translateY(-50%)}.rdp-multiple_months .rdp-caption_end .rdp-nav{position:absolute;top:50%;right:0;transform:translateY(-50%)}.rdp-nav_button{display:inline-flex;align-items:center;justify-content:center;width:var(--rdp-cell-size);height:var(--rdp-cell-size);padding:.25em;border-radius:100%}.rdp-dropdown_year,.rdp-dropdown_month{position:relative;display:inline-flex;align-items:center}.rdp-dropdown{appearance:none;position:absolute;z-index:2;top:0;bottom:0;left:0;width:100%;margin:0;padding:0;cursor:inherit;opacity:0;border:none;background-color:transparent;font-family:inherit;font-size:inherit;line-height:inherit}.rdp-dropdown[disabled]{opacity:unset;color:unset}.rdp-dropdown:focus-visible:not([disabled])+.rdp-caption_label{background-color:var(--rdp-background-color);border:var(--rdp-outline);border-radius:6px}.rdp-dropdown_icon{margin:0 0 0 5px}.rdp-head{border:0}.rdp-head_row,.rdp-row{height:100%}.rdp-head_cell{vertical-align:middle;font-size:.75em;font-weight:700;text-align:center;height:100%;height:var(--rdp-cell-size);padding:0;text-transform:uppercase}.rdp-tbody{border:0}.rdp-tfoot{margin:.5em}.rdp-cell{width:var(--rdp-cell-size);height:100%;height:var(--rdp-cell-size);padding:0;text-align:center}.rdp-weeknumber{font-size:.75em}.rdp-weeknumber,.rdp-day{display:flex;overflow:hidden;align-items:center;justify-content:center;box-sizing:border-box;width:var(--rdp-cell-size);max-width:var(--rdp-cell-size);height:var(--rdp-cell-size);margin:0;border:2px solid transparent;border-radius:100%}.rdp-day_today:not(.rdp-day_outside){font-weight:700}.rdp-day_selected,.rdp-day_selected:focus-visible,.rdp-day_selected:hover{color:var(--rdp-selected-color);opacity:1;background-color:var(--rdp-accent-color)}.rdp-day_outside{opacity:.5}.rdp-day_selected:focus-visible{outline:var(--rdp-outline);outline-offset:2px;z-index:1}.rdp:not([dir=rtl]) .rdp-day_range_start:not(.rdp-day_range_end){border-top-right-radius:0;border-bottom-right-radius:0}.rdp:not([dir=rtl]) .rdp-day_range_end:not(.rdp-day_range_start){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_start:not(.rdp-day_range_end){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_end:not(.rdp-day_range_start){border-top-right-radius:0;border-bottom-right-radius:0}.rdp-day_range_end.rdp-day_range_start{border-radius:100%}.rdp-day_range_middle{border-radius:0}/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-space-x-reverse: 0;--tw-border-style: solid;--tw-gradient-position: initial;--tw-gradient-from: #0000;--tw-gradient-via: #0000;--tw-gradient-to: #0000;--tw-gradient-stops: initial;--tw-gradient-via-stops: initial;--tw-gradient-from-position: 0%;--tw-gradient-via-position: 50%;--tw-gradient-to-position: 100%;--tw-leading: initial;--tw-font-weight: initial;--tw-tracking: initial;--tw-ordinal: initial;--tw-slashed-zero: initial;--tw-numeric-figure: initial;--tw-numeric-spacing: initial;--tw-numeric-fraction: initial;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000;--tw-outline-style: solid;--tw-backdrop-blur: initial;--tw-backdrop-brightness: initial;--tw-backdrop-contrast: initial;--tw-backdrop-grayscale: initial;--tw-backdrop-hue-rotate: initial;--tw-backdrop-invert: initial;--tw-backdrop-opacity: initial;--tw-backdrop-saturate: initial;--tw-backdrop-sepia: initial;--tw-duration: initial;--tw-ease: initial;--tw-scale-x: 1;--tw-scale-y: 1;--tw-scale-z: 1}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100: oklch(.936 .032 17.717);--color-red-300: oklch(.808 .114 19.571);--color-red-400: oklch(.704 .191 22.216);--color-red-500: oklch(.637 .237 25.331);--color-red-600: oklch(.577 .245 27.325);--color-red-700: oklch(.505 .213 27.518);--color-red-900: oklch(.396 .141 25.723);--color-orange-50: oklch(.98 .016 73.684);--color-orange-200: oklch(.901 .076 70.697);--color-orange-500: oklch(.705 .213 47.604);--color-orange-700: oklch(.553 .195 38.402);--color-yellow-400: oklch(.852 .199 91.936);--color-yellow-500: oklch(.795 .184 86.047);--color-green-100: oklch(.962 .044 156.743);--color-green-500: oklch(.723 .219 149.579);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-emerald-50: oklch(.979 .021 166.113);--color-emerald-600: oklch(.596 .145 163.225);--color-blue-50: oklch(.97 .014 254.604);--color-blue-100: oklch(.932 .032 255.585);--color-blue-200: oklch(.882 .059 254.128);--color-blue-300: oklch(.809 .105 251.813);--color-blue-400: oklch(.707 .165 254.624);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-blue-800: oklch(.424 .199 265.638);--color-blue-900: oklch(.379 .146 265.522);--color-indigo-50: oklch(.962 .018 272.314);--color-indigo-600: oklch(.511 .262 276.966);--color-gray-50: oklch(.985 .002 247.839);--color-gray-100: oklch(.967 .003 264.542);--color-gray-200: oklch(.928 .006 264.531);--color-gray-300: oklch(.872 .01 258.338);--color-gray-400: oklch(.707 .022 261.325);--color-gray-500: oklch(.551 .027 264.364);--color-gray-600: oklch(.446 .03 256.802);--color-gray-700: oklch(.373 .034 259.733);--color-gray-800: oklch(.278 .033 256.848);--color-gray-900: oklch(.21 .034 264.665);--color-black: #000;--color-white: #fff;--spacing: .25rem;--container-xs: 20rem;--container-md: 28rem;--container-lg: 32rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-base--line-height: 1.5 ;--text-lg: 1.125rem;--text-lg--line-height: calc(1.75 / 1.125);--text-xl: 1.25rem;--text-xl--line-height: calc(1.75 / 1.25);--text-2xl: 1.5rem;--text-2xl--line-height: calc(2 / 1.5);--text-3xl: 1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semibold: 600;--font-weight-bold: 700;--tracking-wide: .025em;--tracking-wider: .05em;--leading-tight: 1.25;--leading-relaxed: 1.625;--radius-xs: .125rem;--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.\@container\/card-header{container:card-header / inline-size}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-4{top:calc(var(--spacing) * 4)}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-12{bottom:calc(var(--spacing) * 12)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2 / span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-auto{margin-block:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-square{aspect-ratio:1}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-32{height:calc(var(--spacing) * 32)}.h-64{height:calc(var(--spacing) * 64)}.h-\[1\.15rem\]{height:1.15rem}.h-\[120px\]{height:120px}.h-\[200px\]{height:200px}.h-\[280px\]{height:280px}.h-\[300px\]{height:300px}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-\[90vh\]{max-height:90vh}.min-h-\[400px\]{min-height:400px}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-\[100px\]{width:100px}.w-\[120px\]{width:120px}.w-\[140px\]{width:140px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[180px\]{width:180px}.w-\[200px\]{width:200px}.w-\[250px\]{width:250px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-\[200px\]{max-width:200px}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.translate-x-\[-50\%\]{--tw-translate-x: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.-space-x-\[1px\]>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(-1px * var(--tw-space-x-reverse));margin-inline-end:calc(-1px * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style: dashed;border-style:dashed}.border-none{--tw-border-style: none;border-style:none}.border-black{border-color:var(--color-black)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-800{border-color:var(--color-blue-800)}.border-blue-800\/50{border-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.border-blue-800\/50{border-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-800{border-color:var(--color-gray-800)}.border-input{border-color:var(--input)}.border-orange-200{border-color:var(--color-orange-200)}.border-red-600{border-color:var(--color-red-600)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#1e3a8a\]{background-color:#1e3a8a}.bg-\[\#2c7bb6\]{background-color:#2c7bb6}.bg-\[\#4CAF50\]{background-color:#4caf50}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/40{background-color:#0006}@supports (color: color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color: color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-blue-800{background-color:var(--color-blue-800)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:color-mix(in srgb,oklch(.985 .002 247.839) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-50\/50{background-color:color-mix(in oklab,var(--color-gray-50) 50%,transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:color-mix(in srgb,oklch(.928 .006 264.531) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-200\/50{background-color:color-mix(in oklab,var(--color-gray-200) 50%,transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-input-background{background-color:var(--input-background)}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-gradient-to-b{--tw-gradient-position: to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-gray-50{--tw-gradient-from: var(--color-gray-50);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-100{--tw-gradient-to: var(--color-gray-100);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[3px\]{padding:3px}.p-px{padding:1px}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading, var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading, var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading, var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading, var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading: 1;line-height:1}.leading-relaxed{--tw-leading: var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading: var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight: var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight: var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight: var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight: var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking: var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking: var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-\[\#2c7bb6\]{color:#2c7bb6}.text-black{color:var(--color-black)}.text-blue-100{color:var(--color-blue-100)}.text-blue-200{color:var(--color-blue-200)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-muted-foreground{color:var(--muted-foreground)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, )}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, #00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, #0000001a), 0 4px 6px -4px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, #0000001a), 0 8px 10px -6px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-blue-900\/20{--tw-shadow-color: color-mix(in srgb, oklch(.379 .146 265.522) 20%, transparent)}@supports (color: color-mix(in lab,red,red)){.shadow-blue-900\/20{--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-900) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-offset-background{--tw-ring-offset-color: var(--background)}.outline-hidden{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-\[1px\]{--tw-backdrop-blur: blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, )}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration: .2s;transition-duration:.2s}.duration-1000{--tw-duration: 1s;transition-duration:1s}.ease-linear{--tw-ease: linear;transition-timing-function:linear}.outline-none{--tw-outline-style: none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.selection\:bg-primary ::selection,.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection,.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:pb-0:last-child{padding-bottom:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x: 110%;--tw-scale-y: 110%;--tw-scale-z: 110%;scale:var(--tw-scale-x) var(--tw-scale-y)}}@media(hover:hover){.hover\:bg-\[\#43a047\]:hover{background-color:#43a047}}@media(hover:hover){.hover\:bg-\[\#256b9e\]:hover{background-color:#256b9e}}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}}@media(hover:hover){.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}}@media(hover:hover){.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}}@media(hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\:bg-blue-800:hover{background-color:var(--color-blue-800)}}@media(hover:hover){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 30%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in oklab,var(--color-blue-800) 30%,transparent)}}}@media(hover:hover){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}}@media(hover:hover){.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}@media(hover:hover){.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}}@media(hover:hover){.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}}@media(hover:hover){.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){.hover\:bg-red-900\/20:hover{background-color:color-mix(in srgb,oklch(.396 .141 25.723) 20%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-red-900\/20:hover{background-color:color-mix(in oklab,var(--color-red-900) 20%,transparent)}}}@media(hover:hover){.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}}@media(hover:hover){.hover\:bg-yellow-500:hover{background-color:var(--color-yellow-500)}}@media(hover:hover){.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}@media(hover:hover){.hover\:text-gray-600:hover{color:var(--color-gray-600)}}@media(hover:hover){.hover\:text-gray-700:hover{color:var(--color-gray-700)}}@media(hover:hover){.hover\:text-red-600:hover{color:var(--color-red-600)}}@media(hover:hover){.hover\:text-white:hover{color:var(--color-white)}}@media(hover:hover){.hover\:underline:hover{text-decoration-line:underline}}@media(hover:hover){.hover\:opacity-100:hover{opacity:1}}@media(hover:hover){.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:bg-white:focus{background-color:var(--color-white)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\:ring-ring:focus{--tw-ring-color: var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px;--tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: var(--ring)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y: calc(2 * var(--spacing) * -1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x: calc(2 * var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x: calc(2 * var(--spacing) * -1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y: calc(2 * var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:var(--card)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale: .95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale: .95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-switch-background[data-state=unchecked]{background-color:var(--switch-background)}@media(width>=40rem){.sm\:ml-0{margin-left:calc(var(--spacing) * 0)}}@media(width>=40rem){.sm\:w-auto{width:auto}}@media(width>=40rem){.sm\:max-w-\[500px\]{max-width:500px}}@media(width>=40rem){.sm\:max-w-\[600px\]{max-width:600px}}@media(width>=40rem){.sm\:max-w-lg{max-width:var(--container-lg)}}@media(width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=40rem){.sm\:flex-row{flex-direction:row}}@media(width>=40rem){.sm\:items-center{align-items:center}}@media(width>=40rem){.sm\:justify-end{justify-content:flex-end}}@media(width>=40rem){.sm\:text-left{text-align:left}}@media(width>=48rem){.md\:block{display:block}}@media(width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=48rem){.md\:flex-row{flex-direction:row}}@media(width>=48rem){.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}}@media(width>=48rem){.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}}@media(width>=64rem){.lg\:col-span-2{grid-column:span 2 / span 2}}@media(width>=64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:text-muted-foreground:is(.dark *){color:var(--muted-foreground)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}}@media(hover:hover){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:var(--input)}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:var(--foreground)}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.dark\:data-\[state\=unchecked\]\:bg-card-foreground:is(.dark *)[data-state=unchecked]{background-color:var(--card-foreground)}.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:color-mix(in oklab,var(--input) 80%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing) * 0)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}.\[\&\:last-child\]\:pb-6:last-child{padding-bottom:calc(var(--spacing) * 6)}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y: 2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}:root{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size)}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-space-x-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-gradient-position{syntax: "*"; inherits: false}@property --tw-gradient-from{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-via{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-to{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-stops{syntax: "*"; inherits: false}@property --tw-gradient-via-stops{syntax: "*"; inherits: false}@property --tw-gradient-from-position{syntax: "<length-percentage>"; inherits: false; initial-value: 0%;}@property --tw-gradient-via-position{syntax: "<length-percentage>"; inherits: false; initial-value: 50%;}@property --tw-gradient-to-position{syntax: "<length-percentage>"; inherits: false; initial-value: 100%;}@property --tw-leading{syntax: "*"; inherits: false}@property --tw-font-weight{syntax: "*"; inherits: false}@property --tw-tracking{syntax: "*"; inherits: false}@property --tw-ordinal{syntax: "*"; inherits: false}@property --tw-slashed-zero{syntax: "*"; inherits: false}@property --tw-numeric-figure{syntax: "*"; inherits: false}@property --tw-numeric-spacing{syntax: "*"; inherits: false}@property --tw-numeric-fraction{syntax: "*"; inherits: false}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: "<length>"; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-outline-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-backdrop-blur{syntax: "*"; inherits: false}@property --tw-backdrop-brightness{syntax: "*"; inherits: false}@property --tw-backdrop-contrast{syntax: "*"; inherits: false}@property --tw-backdrop-grayscale{syntax: "*"; inherits: false}@property --tw-backdrop-hue-rotate{syntax: "*"; inherits: false}@property --tw-backdrop-invert{syntax: "*"; inherits: false}@property --tw-backdrop-opacity{syntax: "*"; inherits: false}@property --tw-backdrop-saturate{syntax: "*"; inherits: false}@property --tw-backdrop-sepia{syntax: "*"; inherits: false}@property --tw-duration{syntax: "*"; inherits: false}@property --tw-ease{syntax: "*"; inherits: false}@property --tw-scale-x{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-y{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-z{syntax: "*"; inherits: false; initial-value: 1;}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}[data-sonner-toaster]{z-index:10000!important}@font-face{font-family:FreightSans Bold;src:url(/assets/FreightSans%20Bold-CftzBXfG.ttf) format("truetype");font-weight:700;font-style:normal;font-display:swap;unicode-range:U+0000-002F,U+003A-10FFFF}:root{--font-sans: "FreightSans Bold", ui-sans-serif, system-ui, sans-serif;--font-numeric: ui-sans-serif, system-ui, sans-serif}body{font-family:var(--font-sans)}.font-numeric{font-family:var(--font-numeric)!important}
美国版/Food Labeling Management Platform/build/assets/index-Dc47WtG1.css deleted
1 -/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-space-x-reverse: 0;--tw-border-style: solid;--tw-gradient-position: initial;--tw-gradient-from: #0000;--tw-gradient-via: #0000;--tw-gradient-to: #0000;--tw-gradient-stops: initial;--tw-gradient-via-stops: initial;--tw-gradient-from-position: 0%;--tw-gradient-via-position: 50%;--tw-gradient-to-position: 100%;--tw-leading: initial;--tw-font-weight: initial;--tw-tracking: initial;--tw-ordinal: initial;--tw-slashed-zero: initial;--tw-numeric-figure: initial;--tw-numeric-spacing: initial;--tw-numeric-fraction: initial;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000;--tw-outline-style: solid;--tw-backdrop-blur: initial;--tw-backdrop-brightness: initial;--tw-backdrop-contrast: initial;--tw-backdrop-grayscale: initial;--tw-backdrop-hue-rotate: initial;--tw-backdrop-invert: initial;--tw-backdrop-opacity: initial;--tw-backdrop-saturate: initial;--tw-backdrop-sepia: initial;--tw-duration: initial;--tw-ease: initial;--tw-scale-x: 1;--tw-scale-y: 1;--tw-scale-z: 1}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100: oklch(.936 .032 17.717);--color-red-300: oklch(.808 .114 19.571);--color-red-400: oklch(.704 .191 22.216);--color-red-500: oklch(.637 .237 25.331);--color-red-600: oklch(.577 .245 27.325);--color-red-700: oklch(.505 .213 27.518);--color-red-900: oklch(.396 .141 25.723);--color-orange-50: oklch(.98 .016 73.684);--color-orange-200: oklch(.901 .076 70.697);--color-orange-500: oklch(.705 .213 47.604);--color-orange-700: oklch(.553 .195 38.402);--color-yellow-400: oklch(.852 .199 91.936);--color-yellow-500: oklch(.795 .184 86.047);--color-green-100: oklch(.962 .044 156.743);--color-green-500: oklch(.723 .219 149.579);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-emerald-50: oklch(.979 .021 166.113);--color-emerald-600: oklch(.596 .145 163.225);--color-blue-50: oklch(.97 .014 254.604);--color-blue-100: oklch(.932 .032 255.585);--color-blue-200: oklch(.882 .059 254.128);--color-blue-300: oklch(.809 .105 251.813);--color-blue-400: oklch(.707 .165 254.624);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-blue-800: oklch(.424 .199 265.638);--color-blue-900: oklch(.379 .146 265.522);--color-indigo-50: oklch(.962 .018 272.314);--color-indigo-600: oklch(.511 .262 276.966);--color-gray-50: oklch(.985 .002 247.839);--color-gray-100: oklch(.967 .003 264.542);--color-gray-200: oklch(.928 .006 264.531);--color-gray-300: oklch(.872 .01 258.338);--color-gray-400: oklch(.707 .022 261.325);--color-gray-500: oklch(.551 .027 264.364);--color-gray-600: oklch(.446 .03 256.802);--color-gray-700: oklch(.373 .034 259.733);--color-gray-800: oklch(.278 .033 256.848);--color-gray-900: oklch(.21 .034 264.665);--color-black: #000;--color-white: #fff;--spacing: .25rem;--container-xs: 20rem;--container-md: 28rem;--container-lg: 32rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-base--line-height: 1.5 ;--text-lg: 1.125rem;--text-lg--line-height: calc(1.75 / 1.125);--text-xl: 1.25rem;--text-xl--line-height: calc(1.75 / 1.25);--text-2xl: 1.5rem;--text-2xl--line-height: calc(2 / 1.5);--text-3xl: 1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semibold: 600;--font-weight-bold: 700;--tracking-wide: .025em;--tracking-wider: .05em;--leading-tight: 1.25;--leading-relaxed: 1.625;--radius-xs: .125rem;--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.\@container\/card-header{container:card-header / inline-size}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-4{top:calc(var(--spacing) * 4)}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-12{bottom:calc(var(--spacing) * 12)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2 / span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-auto{margin-block:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-square{aspect-ratio:1}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-32{height:calc(var(--spacing) * 32)}.h-64{height:calc(var(--spacing) * 64)}.h-\[1\.15rem\]{height:1.15rem}.h-\[120px\]{height:120px}.h-\[200px\]{height:200px}.h-\[280px\]{height:280px}.h-\[300px\]{height:300px}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-\[90vh\]{max-height:90vh}.min-h-\[400px\]{min-height:400px}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-\[100px\]{width:100px}.w-\[120px\]{width:120px}.w-\[140px\]{width:140px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[180px\]{width:180px}.w-\[200px\]{width:200px}.w-\[250px\]{width:250px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-\[200px\]{max-width:200px}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.translate-x-\[-50\%\]{--tw-translate-x: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.-space-x-\[1px\]>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(-1px * var(--tw-space-x-reverse));margin-inline-end:calc(-1px * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style: dashed;border-style:dashed}.border-none{--tw-border-style: none;border-style:none}.border-black{border-color:var(--color-black)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-800{border-color:var(--color-blue-800)}.border-blue-800\/50{border-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.border-blue-800\/50{border-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-800{border-color:var(--color-gray-800)}.border-input{border-color:var(--input)}.border-orange-200{border-color:var(--color-orange-200)}.border-red-600{border-color:var(--color-red-600)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#1e3a8a\]{background-color:#1e3a8a}.bg-\[\#2c7bb6\]{background-color:#2c7bb6}.bg-\[\#4CAF50\]{background-color:#4caf50}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/40{background-color:#0006}@supports (color: color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color: color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-blue-800{background-color:var(--color-blue-800)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:color-mix(in srgb,oklch(.985 .002 247.839) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-50\/50{background-color:color-mix(in oklab,var(--color-gray-50) 50%,transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:color-mix(in srgb,oklch(.928 .006 264.531) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-200\/50{background-color:color-mix(in oklab,var(--color-gray-200) 50%,transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-input-background{background-color:var(--input-background)}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-gradient-to-b{--tw-gradient-position: to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-gray-50{--tw-gradient-from: var(--color-gray-50);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-100{--tw-gradient-to: var(--color-gray-100);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[3px\]{padding:3px}.p-px{padding:1px}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading, var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading, var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading, var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading, var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading: 1;line-height:1}.leading-relaxed{--tw-leading: var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading: var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight: var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight: var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight: var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight: var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking: var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking: var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-\[\#2c7bb6\]{color:#2c7bb6}.text-black{color:var(--color-black)}.text-blue-100{color:var(--color-blue-100)}.text-blue-200{color:var(--color-blue-200)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-muted-foreground{color:var(--muted-foreground)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, )}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, #00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, #0000001a), 0 4px 6px -4px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, #0000001a), 0 8px 10px -6px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-blue-900\/20{--tw-shadow-color: color-mix(in srgb, oklch(.379 .146 265.522) 20%, transparent)}@supports (color: color-mix(in lab,red,red)){.shadow-blue-900\/20{--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-900) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-offset-background{--tw-ring-offset-color: var(--background)}.outline-hidden{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-\[1px\]{--tw-backdrop-blur: blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, )}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration: .2s;transition-duration:.2s}.duration-1000{--tw-duration: 1s;transition-duration:1s}.ease-linear{--tw-ease: linear;transition-timing-function:linear}.outline-none{--tw-outline-style: none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.selection\:bg-primary ::selection,.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection,.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:pb-0:last-child{padding-bottom:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x: 110%;--tw-scale-y: 110%;--tw-scale-z: 110%;scale:var(--tw-scale-x) var(--tw-scale-y)}}@media(hover:hover){.hover\:bg-\[\#43a047\]:hover{background-color:#43a047}}@media(hover:hover){.hover\:bg-\[\#256b9e\]:hover{background-color:#256b9e}}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}}@media(hover:hover){.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}}@media(hover:hover){.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}}@media(hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\:bg-blue-800:hover{background-color:var(--color-blue-800)}}@media(hover:hover){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 30%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in oklab,var(--color-blue-800) 30%,transparent)}}}@media(hover:hover){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}}@media(hover:hover){.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}@media(hover:hover){.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}}@media(hover:hover){.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}}@media(hover:hover){.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){.hover\:bg-red-900\/20:hover{background-color:color-mix(in srgb,oklch(.396 .141 25.723) 20%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-red-900\/20:hover{background-color:color-mix(in oklab,var(--color-red-900) 20%,transparent)}}}@media(hover:hover){.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}}@media(hover:hover){.hover\:bg-yellow-500:hover{background-color:var(--color-yellow-500)}}@media(hover:hover){.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}@media(hover:hover){.hover\:text-gray-600:hover{color:var(--color-gray-600)}}@media(hover:hover){.hover\:text-gray-700:hover{color:var(--color-gray-700)}}@media(hover:hover){.hover\:text-red-600:hover{color:var(--color-red-600)}}@media(hover:hover){.hover\:text-white:hover{color:var(--color-white)}}@media(hover:hover){.hover\:underline:hover{text-decoration-line:underline}}@media(hover:hover){.hover\:opacity-100:hover{opacity:1}}@media(hover:hover){.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:bg-white:focus{background-color:var(--color-white)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\:ring-ring:focus{--tw-ring-color: var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px;--tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: var(--ring)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y: calc(2 * var(--spacing) * -1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x: calc(2 * var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x: calc(2 * var(--spacing) * -1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y: calc(2 * var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:var(--card)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale: .95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale: .95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-switch-background[data-state=unchecked]{background-color:var(--switch-background)}@media(width>=40rem){.sm\:ml-0{margin-left:calc(var(--spacing) * 0)}}@media(width>=40rem){.sm\:w-auto{width:auto}}@media(width>=40rem){.sm\:max-w-\[500px\]{max-width:500px}}@media(width>=40rem){.sm\:max-w-\[600px\]{max-width:600px}}@media(width>=40rem){.sm\:max-w-lg{max-width:var(--container-lg)}}@media(width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=40rem){.sm\:flex-row{flex-direction:row}}@media(width>=40rem){.sm\:items-center{align-items:center}}@media(width>=40rem){.sm\:justify-end{justify-content:flex-end}}@media(width>=40rem){.sm\:text-left{text-align:left}}@media(width>=48rem){.md\:block{display:block}}@media(width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=48rem){.md\:flex-row{flex-direction:row}}@media(width>=48rem){.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}}@media(width>=48rem){.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}}@media(width>=64rem){.lg\:col-span-2{grid-column:span 2 / span 2}}@media(width>=64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:text-muted-foreground:is(.dark *){color:var(--muted-foreground)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}}@media(hover:hover){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:var(--input)}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:var(--foreground)}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.dark\:data-\[state\=unchecked\]\:bg-card-foreground:is(.dark *)[data-state=unchecked]{background-color:var(--card-foreground)}.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:color-mix(in oklab,var(--input) 80%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing) * 0)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}.\[\&\:last-child\]\:pb-6:last-child{padding-bottom:calc(var(--spacing) * 6)}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y: 2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}:root{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size)}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-space-x-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-gradient-position{syntax: "*"; inherits: false}@property --tw-gradient-from{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-via{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-to{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-stops{syntax: "*"; inherits: false}@property --tw-gradient-via-stops{syntax: "*"; inherits: false}@property --tw-gradient-from-position{syntax: "<length-percentage>"; inherits: false; initial-value: 0%;}@property --tw-gradient-via-position{syntax: "<length-percentage>"; inherits: false; initial-value: 50%;}@property --tw-gradient-to-position{syntax: "<length-percentage>"; inherits: false; initial-value: 100%;}@property --tw-leading{syntax: "*"; inherits: false}@property --tw-font-weight{syntax: "*"; inherits: false}@property --tw-tracking{syntax: "*"; inherits: false}@property --tw-ordinal{syntax: "*"; inherits: false}@property --tw-slashed-zero{syntax: "*"; inherits: false}@property --tw-numeric-figure{syntax: "*"; inherits: false}@property --tw-numeric-spacing{syntax: "*"; inherits: false}@property --tw-numeric-fraction{syntax: "*"; inherits: false}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: "<length>"; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-outline-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-backdrop-blur{syntax: "*"; inherits: false}@property --tw-backdrop-brightness{syntax: "*"; inherits: false}@property --tw-backdrop-contrast{syntax: "*"; inherits: false}@property --tw-backdrop-grayscale{syntax: "*"; inherits: false}@property --tw-backdrop-hue-rotate{syntax: "*"; inherits: false}@property --tw-backdrop-invert{syntax: "*"; inherits: false}@property --tw-backdrop-opacity{syntax: "*"; inherits: false}@property --tw-backdrop-saturate{syntax: "*"; inherits: false}@property --tw-backdrop-sepia{syntax: "*"; inherits: false}@property --tw-duration{syntax: "*"; inherits: false}@property --tw-ease{syntax: "*"; inherits: false}@property --tw-scale-x{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-y{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-z{syntax: "*"; inherits: false; initial-value: 1;}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}[data-sonner-toaster]{z-index:10000!important}@font-face{font-family:FreightSans Bold;src:url(/assets/FreightSans%20Bold-CftzBXfG.ttf) format("truetype");font-weight:700;font-style:normal;font-display:swap;unicode-range:U+0000-002F,U+003A-10FFFF}:root{--font-sans: "FreightSans Bold", ui-sans-serif, system-ui, sans-serif;--font-numeric: ui-sans-serif, system-ui, sans-serif}body{font-family:var(--font-sans)}.font-numeric{font-family:var(--font-numeric)!important}  
美国版/Food Labeling Management Platform/build/index.html
@@ -5,8 +5,8 @@ @@ -5,8 +5,8 @@
5 <meta charset="UTF-8" /> 5 <meta charset="UTF-8" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <title>Food Labeling Management Platform</title> 7 <title>Food Labeling Management Platform</title>
8 - <script type="module" crossorigin src="/assets/index-BHd3BZos.js"></script>  
9 - <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css"> 8 + <script type="module" crossorigin src="/assets/index-ChVLtgeV.js"></script>
  9 + <link rel="stylesheet" crossorigin href="/assets/index-DLL5VTnd.css">
10 </head> 10 </head>
11 11
12 <body> 12 <body>
美国版/Food Labeling Management Platform/src/App.tsx
@@ -33,6 +33,8 @@ function AuthedApp() { @@ -33,6 +33,8 @@ function AuthedApp() {
33 /** Dashboard「View Reports」:与 reportsTargetTab 配合,仅在意图跳转时递增 */ 33 /** Dashboard「View Reports」:与 reportsTargetTab 配合,仅在意图跳转时递增 */
34 const [reportsOpenKey, setReportsOpenKey] = useState(0); 34 const [reportsOpenKey, setReportsOpenKey] = useState(0);
35 const [reportsTargetTab, setReportsTargetTab] = useState<'print-log' | 'label-report'>('print-log'); 35 const [reportsTargetTab, setReportsTargetTab] = useState<'print-log' | 'label-report'>('print-log');
  36 + /** 标签模板编辑器全屏:Layout 隐藏侧栏/顶栏 */
  37 + const [labelTemplateEditorFullscreen, setLabelTemplateEditorFullscreen] = useState(false);
36 38
37 const resolveView = (name: string) => { 39 const resolveView = (name: string) => {
38 const s = (name ?? "").trim(); 40 const s = (name ?? "").trim();
@@ -67,6 +69,9 @@ function AuthedApp() { @@ -67,6 +69,9 @@ function AuthedApp() {
67 if (resolvedView !== 'Reports') { 69 if (resolvedView !== 'Reports') {
68 setReportsOpenKey(0); 70 setReportsOpenKey(0);
69 } 71 }
  72 + if (resolvedView !== 'Label Templates') {
  73 + setLabelTemplateEditorFullscreen(false);
  74 + }
70 }, [resolvedView]); 75 }, [resolvedView]);
71 76
72 if (!auth.token) { 77 if (!auth.token) {
@@ -128,6 +133,7 @@ function AuthedApp() { @@ -128,6 +133,7 @@ function AuthedApp() {
128 onViewChange={setCurrentView} 133 onViewChange={setCurrentView}
129 labelCreateOpenSeq={labelCreateOpenSeq} 134 labelCreateOpenSeq={labelCreateOpenSeq}
130 onLabelCreateIntentConsumed={consumeLabelCreateIntent} 135 onLabelCreateIntentConsumed={consumeLabelCreateIntent}
  136 + onLabelTemplateEditorLayoutOverlay={setLabelTemplateEditorFullscreen}
131 /> 137 />
132 ); 138 );
133 default: 139 default:
@@ -137,7 +143,13 @@ function AuthedApp() { @@ -137,7 +143,13 @@ function AuthedApp() {
137 143
138 return ( 144 return (
139 <> 145 <>
140 - <Layout currentView={resolvedView} setCurrentView={navigateToView} menus={auth.menus} onLogout={auth.logout}> 146 + <Layout
  147 + currentView={resolvedView}
  148 + setCurrentView={navigateToView}
  149 + menus={auth.menus}
  150 + onLogout={auth.logout}
  151 + hideAppChrome={labelTemplateEditorFullscreen}
  152 + >
141 {renderView()} 153 {renderView()}
142 </Layout> 154 </Layout>
143 </> 155 </>
美国版/Food Labeling Management Platform/src/components/bulk/batch-import-dialog.tsx 0 → 100644
  1 +import React, { useRef, useState } from "react";
  2 +import { Button } from "../ui/button";
  3 +import {
  4 + Dialog,
  5 + DialogContent,
  6 + DialogDescription,
  7 + DialogFooter,
  8 + DialogHeader,
  9 + DialogTitle,
  10 +} from "../ui/dialog";
  11 +import { Label } from "../ui/label";
  12 +import { toast } from "sonner";
  13 +import { ApiError } from "../../lib/apiClient";
  14 +
  15 +export type BatchImportDialogProps = {
  16 + open: boolean;
  17 + onOpenChange: (open: boolean) => void;
  18 + title: string;
  19 + description?: string;
  20 + /** 弹框底部「下载模板」 */
  21 + onDownloadTemplate: () => void | Promise<void>;
  22 + /** 选择文件后点击 Import 上传 */
  23 + onImportFile: (file: File) => Promise<{ successCount: number; failCount: number }>;
  24 + downloadingTemplate?: boolean;
  25 +};
  26 +
  27 +export function BatchImportDialog({
  28 + open,
  29 + onOpenChange,
  30 + title,
  31 + description,
  32 + onDownloadTemplate,
  33 + onImportFile,
  34 + downloadingTemplate = false,
  35 +}: BatchImportDialogProps) {
  36 + const inputRef = useRef<HTMLInputElement | null>(null);
  37 + const [file, setFile] = useState<File | null>(null);
  38 + const [busy, setBusy] = useState(false);
  39 +
  40 + const reset = () => {
  41 + setFile(null);
  42 + if (inputRef.current) inputRef.current.value = "";
  43 + };
  44 +
  45 + return (
  46 + <Dialog
  47 + open={open}
  48 + onOpenChange={(v) => {
  49 + if (!v) reset();
  50 + onOpenChange(v);
  51 + }}
  52 + >
  53 + <DialogContent className="sm:max-w-md">
  54 + <DialogHeader>
  55 + <DialogTitle>{title}</DialogTitle>
  56 + {description ? <DialogDescription>{description}</DialogDescription> : null}
  57 + </DialogHeader>
  58 + <div className="flex flex-col gap-4 py-2">
  59 + <div className="space-y-2">
  60 + <Label htmlFor="batch-import-file">Excel file (.xlsx)</Label>
  61 + <input
  62 + id="batch-import-file"
  63 + ref={inputRef}
  64 + type="file"
  65 + accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  66 + className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border file:border-gray-300 file:bg-white file:px-3 file:py-2 file:text-sm file:font-medium file:text-gray-900 hover:file:bg-gray-50"
  67 + onChange={(e) => {
  68 + const f = e.target.files?.[0] ?? null;
  69 + setFile(f);
  70 + }}
  71 + />
  72 + </div>
  73 + </div>
  74 + <div className="flex justify-center pb-2">
  75 + <Button
  76 + type="button"
  77 + variant="outline"
  78 + className="w-full sm:w-auto"
  79 + disabled={downloadingTemplate}
  80 + onClick={() => void onDownloadTemplate()}
  81 + >
  82 + {downloadingTemplate ? "Downloading…" : "Download template"}
  83 + </Button>
  84 + </div>
  85 + <DialogFooter className="gap-2 sm:gap-0">
  86 + <Button
  87 + type="button"
  88 + variant="outline"
  89 + onClick={() => {
  90 + reset();
  91 + onOpenChange(false);
  92 + }}
  93 + >
  94 + Cancel
  95 + </Button>
  96 + <Button
  97 + type="button"
  98 + disabled={!file || busy}
  99 + onClick={async () => {
  100 + if (!file) return;
  101 + setBusy(true);
  102 + try {
  103 + const r = await onImportFile(file);
  104 + toast.success("Import finished", {
  105 + description: `Success: ${r.successCount}, failed: ${r.failCount}`,
  106 + });
  107 + reset();
  108 + onOpenChange(false);
  109 + } catch (e) {
  110 + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Import failed.";
  111 + toast.error("Import failed", { description: msg });
  112 + } finally {
  113 + setBusy(false);
  114 + }
  115 + }}
  116 + >
  117 + {busy ? "Importing…" : "Import"}
  118 + </Button>
  119 + </DialogFooter>
  120 + </DialogContent>
  121 + </Dialog>
  122 + );
  123 +}
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx
@@ -40,6 +40,13 @@ import { @@ -40,6 +40,13 @@ import {
40 serializePrintInputOffset, 40 serializePrintInputOffset,
41 tryParsePrintInputOffsetStored, 41 tryParsePrintInputOffsetStored,
42 } from '../../lib/labelFormDatePreview'; 42 } from '../../lib/labelFormDatePreview';
  43 +import {
  44 + foldNutritionCompositeKeysIntoDefaults,
  45 + hydrateRowFieldValuesWithNutritionColumns,
  46 + listNutritionManualFieldSpecs,
  47 + nutritionCompositeFieldKey,
  48 + type NutritionManualFieldSpec,
  49 +} from '../../lib/nutritionManualEntry';
43 import type { ProductDto } from '../../types/product'; 50 import type { ProductDto } from '../../types/product';
44 import type { LabelTypeDto } from '../../types/labelType'; 51 import type { LabelTypeDto } from '../../types/labelType';
45 52
@@ -168,11 +175,31 @@ export function LabelTemplateDataEntryView({ @@ -168,11 +175,31 @@ export function LabelTemplateDataEntryView({
168 const [templateTitle, setTemplateTitle] = useState(''); 175 const [templateTitle, setTemplateTitle] = useState('');
169 /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */ 176 /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */
170 const [templateDto, setTemplateDto] = useState<LabelTemplateDto | null>(null); 177 const [templateDto, setTemplateDto] = useState<LabelTemplateDto | null>(null);
171 - const [printFields, setPrintFields] = useState<LabelElement[]>([]);  
172 const [products, setProducts] = useState<ProductDto[]>([]); 178 const [products, setProducts] = useState<ProductDto[]>([]);
173 const [types, setTypes] = useState<LabelTypeDto[]>([]); 179 const [types, setTypes] = useState<LabelTypeDto[]>([]);
174 const [rows, setRows] = useState<TemplateDataEntryRow[]>([]); 180 const [rows, setRows] = useState<TemplateDataEntryRow[]>([]);
175 181
  182 + const sortedTemplateElements = useMemo(
  183 + () => sortTemplateElementsForDisplay((templateDto?.elements ?? []) as LabelElement[]),
  184 + [templateDto],
  185 + );
  186 +
  187 + const dataColumns = useMemo(() => {
  188 + const cols: Array<
  189 + | { kind: "element"; el: LabelElement }
  190 + | { kind: "nutrition"; parent: LabelElement; spec: NutritionManualFieldSpec }
  191 + > = [];
  192 + for (const el of sortedTemplateElements) {
  193 + if (isDataEntryTableColumnElement(el)) cols.push({ kind: "element", el });
  194 + if (canonicalElementType(el.type) === "NUTRITION") {
  195 + for (const spec of listNutritionManualFieldSpecs(el)) {
  196 + cols.push({ kind: "nutrition", parent: el, spec });
  197 + }
  198 + }
  199 + }
  200 + return cols;
  201 + }, [sortedTemplateElements]);
  202 +
176 const productOptions = useMemo( 203 const productOptions = useMemo(
177 () => 204 () =>
178 products.map((p) => { 205 products.map((p) => {
@@ -209,11 +236,6 @@ export function LabelTemplateDataEntryView({ @@ -209,11 +236,6 @@ export function LabelTemplateDataEntryView({
209 (tpl.templateCode ?? tpl.id ?? '').trim() || 236 (tpl.templateCode ?? tpl.id ?? '').trim() ||
210 templateCode; 237 templateCode;
211 setTemplateTitle(title); 238 setTemplateTitle(title);
212 - const elements = sortTemplateElementsForDisplay(  
213 - (tpl.elements ?? []) as LabelElement[],  
214 - )  
215 - .filter(isDataEntryTableColumnElement);  
216 - setPrintFields(elements);  
217 setProducts(prodRes.items ?? []); 239 setProducts(prodRes.items ?? []);
218 setTypes(typeRes.items ?? []); 240 setTypes(typeRes.items ?? []);
219 setTemplateDto(tpl); 241 setTemplateDto(tpl);
@@ -230,7 +252,10 @@ export function LabelTemplateDataEntryView({ @@ -230,7 +252,10 @@ export function LabelTemplateDataEntryView({
230 id: newRowId(), 252 id: newRowId(),
231 productId: d.productId, 253 productId: d.productId,
232 labelTypeId: d.labelTypeId, 254 labelTypeId: d.labelTypeId,
233 - fieldValues: { ...d.defaultValues }, 255 + fieldValues: hydrateRowFieldValuesWithNutritionColumns(
  256 + { ...d.defaultValues },
  257 + (tpl.elements ?? []) as LabelElement[],
  258 + ),
234 })), 259 })),
235 ); 260 );
236 } else { 261 } else {
@@ -249,7 +274,6 @@ export function LabelTemplateDataEntryView({ @@ -249,7 +274,6 @@ export function LabelTemplateDataEntryView({
249 description: e instanceof Error ? e.message : 'Please try again.', 274 description: e instanceof Error ? e.message : 'Please try again.',
250 }); 275 });
251 setTemplateTitle(templateCode); 276 setTemplateTitle(templateCode);
252 - setPrintFields([]);  
253 setRows([]); 277 setRows([]);
254 setTemplateDto(null); 278 setTemplateDto(null);
255 } 279 }
@@ -312,10 +336,22 @@ export function LabelTemplateDataEntryView({ @@ -312,10 +336,22 @@ export function LabelTemplateDataEntryView({
312 } 336 }
313 337
314 const validRows = rows.filter((r) => r.productId.trim() && r.labelTypeId.trim()); 338 const validRows = rows.filter((r) => r.productId.trim() && r.labelTypeId.trim());
  339 + const fullElements = sortTemplateElementsForDisplay(
  340 + (templateDto.elements ?? []) as LabelElement[],
  341 + );
315 const templateProductDefaults = validRows.map((r, i) => { 342 const templateProductDefaults = validRows.map((r, i) => {
  343 + const folded = foldNutritionCompositeKeysIntoDefaults(r.fieldValues, fullElements);
316 const defaultValues: Record<string, string> = {}; 344 const defaultValues: Record<string, string> = {};
317 - for (const f of printFields) {  
318 - defaultValues[f.id] = normalizeDateTimeFieldForSave(f, r.fieldValues[f.id] ?? ''); 345 + for (const col of dataColumns) {
  346 + if (col.kind === "element") {
  347 + defaultValues[col.el.id] = normalizeDateTimeFieldForSave(col.el, folded[col.el.id] ?? "");
  348 + }
  349 + }
  350 + for (const el of fullElements) {
  351 + if (canonicalElementType(el.type) === "NUTRITION") {
  352 + const j = folded[el.id];
  353 + if (j) defaultValues[el.id] = j;
  354 + }
319 } 355 }
320 return { 356 return {
321 productId: r.productId.trim(), 357 productId: r.productId.trim(),
@@ -325,9 +361,6 @@ export function LabelTemplateDataEntryView({ @@ -325,9 +361,6 @@ export function LabelTemplateDataEntryView({
325 }; 361 };
326 }); 362 });
327 363
328 - const fullElements = sortTemplateElementsForDisplay(  
329 - (templateDto.elements ?? []) as LabelElement[],  
330 - );  
331 if (fullElements.length === 0) { 364 if (fullElements.length === 0) {
332 toast.error('Template has no elements', { description: 'Cannot save this template.' }); 365 toast.error('Template has no elements', { description: 'Cannot save this template.' });
333 return; 366 return;
@@ -362,7 +395,7 @@ export function LabelTemplateDataEntryView({ @@ -362,7 +395,7 @@ export function LabelTemplateDataEntryView({
362 } finally { 395 } finally {
363 setSaving(false); 396 setSaving(false);
364 } 397 }
365 - }, [templateCode, templateDto, rows, printFields]); 398 + }, [templateCode, templateDto, rows, dataColumns]);
366 399
367 return ( 400 return (
368 <div className="h-full flex flex-col min-h-0"> 401 <div className="h-full flex flex-col min-h-0">
@@ -407,23 +440,21 @@ export function LabelTemplateDataEntryView({ @@ -407,23 +440,21 @@ export function LabelTemplateDataEntryView({
407 440
408 <p className="text-sm text-gray-600 py-3 shrink-0"> 441 <p className="text-sm text-gray-600 py-3 shrink-0">
409 Bind product and label type per row. Values are saved with the template (edit API) as{' '} 442 Bind product and label type per row. Values are saved with the template (edit API) as{' '}
410 - <span className="font-medium">templateProductDefaults</span> (interface doc section 4.4). Only{' '}  
411 - <span className="font-medium">manual input</span> controls appear here (  
412 - <span className="font-medium">PRINT_INPUT</span> and Duration series). Non-manual controls such as{' '}  
413 - <span className="font-medium">AUTO_DB / NUTRITION</span> are excluded.{' '}  
414 - <span className="font-medium">BARCODE</span> is excluded here and must be generated from print-time  
415 - input/data. Date / time / duration columns use <span className="font-medium">unit + value</span>; stored as  
416 - JSON with <span className="font-medium">unit</span> and <span className="font-medium">value</span> keys, then  
417 - resolved at App print preview using current time and each field&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 </p> 450 </p>
420 451
421 <div className="flex-1 min-h-0 overflow-auto rounded-md border bg-white shadow-sm"> 452 <div className="flex-1 min-h-0 overflow-auto rounded-md border bg-white shadow-sm">
422 {loading ? ( 453 {loading ? (
423 <div className="p-10 text-center text-sm text-gray-500">Loading…</div> 454 <div className="p-10 text-center text-sm text-gray-500">Loading…</div>
424 - ) : printFields.length === 0 ? ( 455 + ) : dataColumns.length === 0 ? (
425 <div className="p-10 text-center text-sm text-gray-600"> 456 <div className="p-10 text-center text-sm text-gray-600">
426 - No manual input fields (<span className="font-medium">PRINT_INPUT / Duration series</span>) in this template. 457 + No manual input or nutrition columns in this template.
427 </div> 458 </div>
428 ) : ( 459 ) : (
429 <Table> 460 <Table>
@@ -435,13 +466,17 @@ export function LabelTemplateDataEntryView({ @@ -435,13 +466,17 @@ export function LabelTemplateDataEntryView({
435 <TableHead className="font-bold text-gray-900 w-[180px] min-w-[140px]"> 466 <TableHead className="font-bold text-gray-900 w-[180px] min-w-[140px]">
436 Label type 467 Label type
437 </TableHead> 468 </TableHead>
438 - {printFields.map((f) => ( 469 + {dataColumns.map((col) => (
439 <TableHead 470 <TableHead
440 - key={f.id} 471 + key={
  472 + col.kind === 'element'
  473 + ? col.el.id
  474 + : nutritionCompositeFieldKey(col.parent.id, col.spec.subKey)
  475 + }
441 className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap" 476 className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap"
442 - title={f.id} 477 + title={col.kind === 'element' ? col.el.id : `${col.parent.id} · ${col.spec.subKey}`}
443 > 478 >
444 - {dataEntryColumnLabel(f)} 479 + {col.kind === 'element' ? dataEntryColumnLabel(col.el) : col.spec.columnLabel}
445 </TableHead> 480 </TableHead>
446 ))} 481 ))}
447 <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead> 482 <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead>
@@ -468,13 +503,39 @@ export function LabelTemplateDataEntryView({ @@ -468,13 +503,39 @@ export function LabelTemplateDataEntryView({
468 searchPlaceholder="Search type…" 503 searchPlaceholder="Search type…"
469 /> 504 />
470 </TableCell> 505 </TableCell>
471 - {printFields.map((f) => (  
472 - <TableCell key={f.id} className="align-top py-2">  
473 - <DataEntryValueCell  
474 - element={f}  
475 - value={row.fieldValues[f.id] ?? ''}  
476 - onValueChange={(v) => setFieldValue(row.id, f.id, v)}  
477 - /> 506 + {dataColumns.map((col) => (
  507 + <TableCell
  508 + key={
  509 + col.kind === 'element'
  510 + ? col.el.id
  511 + : nutritionCompositeFieldKey(col.parent.id, col.spec.subKey)
  512 + }
  513 + className="align-top py-2"
  514 + >
  515 + {col.kind === 'element' ? (
  516 + <DataEntryValueCell
  517 + element={col.el}
  518 + value={row.fieldValues[col.el.id] ?? ''}
  519 + onValueChange={(v) => setFieldValue(row.id, col.el.id, v)}
  520 + />
  521 + ) : (
  522 + <Input
  523 + value={
  524 + row.fieldValues[
  525 + nutritionCompositeFieldKey(col.parent.id, col.spec.subKey)
  526 + ] ?? ''
  527 + }
  528 + onChange={(e) =>
  529 + setFieldValue(
  530 + row.id,
  531 + nutritionCompositeFieldKey(col.parent.id, col.spec.subKey),
  532 + e.target.value,
  533 + )
  534 + }
  535 + placeholder="—"
  536 + className="h-10 border-gray-300 max-w-[220px]"
  537 + />
  538 + )}
478 </TableCell> 539 </TableCell>
479 ))} 540 ))}
480 <TableCell className="text-center align-top py-2"> 541 <TableCell className="text-center align-top py-2">
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/ElementsPanel.tsx
@@ -83,17 +83,62 @@ interface ElementsPanelProps { @@ -83,17 +83,62 @@ interface ElementsPanelProps {
83 ) => void; 83 ) => void;
84 } 84 }
85 85
  86 +function sectionSurfaceStyle(title: string): React.CSSProperties {
  87 + const t = title.trim().toLowerCase();
  88 + if (t === "template") {
  89 + return {
  90 + backgroundColor: "#dbeafe",
  91 + border: "1px solid #93c5fd",
  92 + borderRadius: 8,
  93 + padding: 8,
  94 + };
  95 + }
  96 + if (t === "label") {
  97 + return {
  98 + backgroundColor: "#e0f2fe",
  99 + border: "1px solid #7dd3fc",
  100 + borderRadius: 8,
  101 + padding: 8,
  102 + };
  103 + }
  104 + if (t === "auto-generated") {
  105 + return {
  106 + backgroundColor: "#ede9fe",
  107 + border: "1px solid #c4b5fd",
  108 + borderRadius: 8,
  109 + padding: 8,
  110 + };
  111 + }
  112 + if (t === "print input") {
  113 + return {
  114 + backgroundColor: "#ffedd5",
  115 + border: "1px solid #fdba74",
  116 + borderRadius: 8,
  117 + padding: 8,
  118 + };
  119 + }
  120 + return {
  121 + backgroundColor: "#f1f5f9",
  122 + border: "1px solid #cbd5e1",
  123 + borderRadius: 8,
  124 + padding: 8,
  125 + };
  126 +}
  127 +
86 export function ElementsPanel({ onAddElement }: ElementsPanelProps) { 128 export function ElementsPanel({ onAddElement }: ElementsPanelProps) {
87 return ( 129 return (
88 - <div className="w-44 shrink-0 border-r border-gray-200 bg-white flex flex-col h-full">  
89 - <div className="px-2 py-2 border-b border-gray-200 font-semibold text-gray-800 text-sm"> 130 + <div className="w-44 shrink-0 border-r border-slate-200 bg-slate-50 flex flex-col h-full min-h-0">
  131 + <div
  132 + className="px-2 py-2 border-b border-slate-200 font-semibold text-slate-800 text-sm shrink-0"
  133 + style={{ backgroundColor: "#eff6ff", borderBottomColor: "#93c5fd" }}
  134 + >
90 Elements 135 Elements
91 </div> 136 </div>
92 - <ScrollArea className="flex-1">  
93 - <div className="p-1.5 space-y-3"> 137 + <ScrollArea className="flex-1 min-h-0 [&_[data-slot=scroll-area-viewport]]:bg-transparent">
  138 + <div className="p-2 space-y-3">
94 {ELEMENT_CATEGORIES.map((cat) => ( 139 {ELEMENT_CATEGORIES.map((cat) => (
95 - <div key={cat.title}>  
96 - <div className="px-2 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide"> 140 + <div key={cat.title} style={sectionSurfaceStyle(cat.title)}>
  141 + <div className="px-2 py-1 text-xs font-medium text-gray-600 uppercase tracking-wide">
97 {cat.title} 142 {cat.title}
98 </div> 143 </div>
99 {cat.subtitle && ( 144 {cat.subtitle && (
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
@@ -225,8 +225,9 @@ function RulerBarHorizontal({ @@ -225,8 +225,9 @@ function RulerBarHorizontal({
225 } 225 }
226 const h = RULER_H; 226 const h = RULER_H;
227 const pxPerDisplayUnit = paperWidthPx / displaySpan; 227 const pxPerDisplayUnit = paperWidthPx / displaySpan;
228 - const totalU = Math.max(displaySpan, rulerTotalWidthPx / pxPerDisplayUnit);  
229 - const xAtU = (u: number) => u * pxPerDisplayUnit; 228 + /** 标尺几何中心为刻度 0,向左为负、向右为正 */
  229 + const centerPx = rulerTotalWidthPx / 2;
  230 + const xAtSignedUnit = (u: number) => centerPx + u * pxPerDisplayUnit;
230 const nodes: React.ReactNode[] = []; 231 const nodes: React.ReactNode[] = [];
231 232
232 let labelStep = 1; 233 let labelStep = 1;
@@ -237,38 +238,37 @@ function RulerBarHorizontal({ @@ -237,38 +238,37 @@ function RulerBarHorizontal({
237 } 238 }
238 239
239 const minorDivisions = displayUnit === "inch" ? 8 : 10; 240 const minorDivisions = displayUnit === "inch" ? 8 : 10;
240 - const intMax = Math.min(5000, Math.floor(totalU + 1e-6));  
241 - for (let k = 0; k <= intMax; k++) {  
242 - if (k > totalU + 1e-6) break;  
243 - const x = xAtU(k); 241 + const kMin = Math.floor((0 - centerPx) / pxPerDisplayUnit) - 2;
  242 + const kMax = Math.ceil((rulerTotalWidthPx - centerPx) / pxPerDisplayUnit) + 2;
  243 + const kLo = Math.max(-5000, Math.min(5000, kMin));
  244 + const kHi = Math.max(-5000, Math.min(5000, kMax));
  245 +
  246 + for (let k = kLo; k <= kHi; k++) {
  247 + const x = xAtSignedUnit(k);
  248 + if (x < -8 || x > rulerTotalWidthPx + 8) continue;
244 const showLabel = k === 0 || k % labelStep === 0; 249 const showLabel = k === 0 || k % labelStep === 0;
245 nodes.push( 250 nodes.push(
246 <g key={`maj-${k}`}> 251 <g key={`maj-${k}`}>
247 <line x1={x} y1={h} x2={x} y2={4} stroke="#9ca3af" strokeWidth={1} /> 252 <line x1={x} y1={h} x2={x} y2={4} stroke="#9ca3af" strokeWidth={1} />
248 {showLabel ? ( 253 {showLabel ? (
249 <text 254 <text
250 - x={k === 0 ? 3 : x} 255 + x={x}
251 y={12} 256 y={12}
252 fontSize={8} 257 fontSize={8}
253 fill="#4b5563" 258 fill="#4b5563"
254 className="select-none font-mono" 259 className="select-none font-mono"
255 - textAnchor={k === 0 ? "start" : "middle"} 260 + textAnchor="middle"
256 > 261 >
257 {k} 262 {k}
258 </text> 263 </text>
259 ) : null} 264 ) : null}
260 </g>, 265 </g>,
261 ); 266 );
262 - const next = Math.min(k + 1, totalU);  
263 - if (next - k < 0.0001) continue;  
264 - if (k + 1e-6 >= totalU) break;  
265 - const partEnd = Math.min(k + 1, totalU);  
266 const midMinor = Math.floor(minorDivisions / 2); 267 const midMinor = Math.floor(minorDivisions / 2);
267 for (let s = 1; s < minorDivisions; s++) { 268 for (let s = 1; s < minorDivisions; s++) {
268 const u = k + s / minorDivisions; 269 const u = k + s / minorDivisions;
269 - if (u >= totalU) break;  
270 - if (u > partEnd + 1e-9) break;  
271 - const x2 = xAtU(u); 270 + const x2 = xAtSignedUnit(u);
  271 + if (x2 < -4 || x2 > rulerTotalWidthPx + 4) continue;
272 const y2 = s === midMinor ? 10 : 12; 272 const y2 = s === midMinor ? 10 : 12;
273 nodes.push( 273 nodes.push(
274 <line 274 <line
@@ -936,6 +936,8 @@ interface LabelCanvasProps { @@ -936,6 +936,8 @@ interface LabelCanvasProps {
936 scale?: number; 936 scale?: number;
937 onZoomIn?: () => void; 937 onZoomIn?: () => void;
938 onZoomOut?: () => void; 938 onZoomOut?: () => void;
  939 + /** 将缩放还原为 100%(与顶部标尺物理尺寸一致),并重新居中画布 */
  940 + onResetZoom?: () => void;
939 onPreview?: () => void; 941 onPreview?: () => void;
940 /** 为 true 时不在预览工具栏显示画布尺寸预设(改由顶部表单控制) */ 942 /** 为 true 时不在预览工具栏显示画布尺寸预设(改由顶部表单控制) */
941 hideToolbarPresetSize?: boolean; 943 hideToolbarPresetSize?: boolean;
@@ -969,6 +971,7 @@ export function LabelCanvas({ @@ -969,6 +971,7 @@ export function LabelCanvas({
969 scale = 1, 971 scale = 1,
970 onZoomIn, 972 onZoomIn,
971 onZoomOut, 973 onZoomOut,
  974 + onResetZoom,
972 onPreview, 975 onPreview,
973 hideToolbarPresetSize = false, 976 hideToolbarPresetSize = false,
974 }: LabelCanvasProps) { 977 }: LabelCanvasProps) {
@@ -1006,6 +1009,7 @@ export function LabelCanvas({ @@ -1006,6 +1009,7 @@ export function LabelCanvas({
1006 1009
1007 const baseW = unitToPx(template.width, template.unit); 1010 const baseW = unitToPx(template.width, template.unit);
1008 const baseH = unitToPx(template.height, template.unit); 1011 const baseH = unitToPx(template.height, template.unit);
  1012 + /** 缩放后的实际占位,用于滚动区域与居中,避免放大后画布被裁切 */
1009 const widthPx = baseW * scale; 1013 const widthPx = baseW * scale;
1010 const heightPx = baseH * scale; 1014 const heightPx = baseH * scale;
1011 const showGrid = template.showGrid !== false; 1015 const showGrid = template.showGrid !== false;
@@ -1343,21 +1347,23 @@ export function LabelCanvas({ @@ -1343,21 +1347,23 @@ export function LabelCanvas({
1343 }; 1347 };
1344 }, [paperResizeCursor]); 1348 }, [paperResizeCursor]);
1345 1349
1346 - // 画布初始居中:挂载或尺寸/缩放变化后让内容居中  
1347 - useEffect(() => { 1350 + const centerScrollInViewport = useCallback(() => {
1348 const el = scrollContainerRef.current; 1351 const el = scrollContainerRef.current;
1349 if (!el) return; 1352 if (!el) return;
1350 - const center = () => { 1353 + const run = () => {
1351 el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2); 1354 el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2);
1352 el.scrollTop = Math.max(0, (el.scrollHeight - el.clientHeight) / 2); 1355 el.scrollTop = Math.max(0, (el.scrollHeight - el.clientHeight) / 2);
1353 }; 1356 };
1354 - const raf = requestAnimationFrame(center);  
1355 - const t = setTimeout(center, 100);  
1356 - return () => {  
1357 - cancelAnimationFrame(raf);  
1358 - clearTimeout(t);  
1359 - };  
1360 - }, [scale, baseW, baseH]); 1357 + requestAnimationFrame(() => requestAnimationFrame(run));
  1358 + }, []);
  1359 +
  1360 + // 缩放或纸张尺寸变化:清空平移偏移,并把画布重新滚到视口中央,避免放大后靠边被遮挡
  1361 + useEffect(() => {
  1362 + setPanOffset({ x: 0, y: 0 });
  1363 + centerScrollInViewport();
  1364 + const t = window.setTimeout(centerScrollInViewport, 80);
  1365 + return () => window.clearTimeout(t);
  1366 + }, [scale, baseW, baseH, rulerLayoutWidth, centerScrollInViewport]);
1361 1367
1362 // Keyboard navigation for elements 1368 // Keyboard navigation for elements
1363 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { 1369 const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -1522,6 +1528,16 @@ export function LabelCanvas({ @@ -1522,6 +1528,16 @@ export function LabelCanvas({
1522 + 1528 +
1523 </button> 1529 </button>
1524 </div> 1530 </div>
  1531 + {onResetZoom ? (
  1532 + <button
  1533 + type="button"
  1534 + onClick={onResetZoom}
  1535 + className="h-8 px-3 rounded border border-blue-200 bg-blue-50 text-blue-800 hover:bg-blue-100 text-xs font-medium shadow-sm transition-all active:scale-95 shrink-0"
  1536 + title="Reset zoom to 100% (match ruler canvas size, e.g. 3×2 inch)"
  1537 + >
  1538 + Restore size
  1539 + </button>
  1540 + ) : null}
1525 <Select 1541 <Select
1526 value={previewRulerUnit} 1542 value={previewRulerUnit}
1527 onValueChange={(v: PreviewRulerDisplayUnit) => setPreviewRulerUnit(v)} 1543 onValueChange={(v: PreviewRulerDisplayUnit) => setPreviewRulerUnit(v)}
@@ -1587,12 +1603,12 @@ export function LabelCanvas({ @@ -1587,12 +1603,12 @@ export function LabelCanvas({
1587 /> 1603 />
1588 </div> 1604 </div>
1589 <div className="flex w-full min-w-0 justify-center"> 1605 <div className="flex w-full min-w-0 justify-center">
1590 - <div className="shrink-0" style={{ width: widthPx }}> 1606 + <div className="shrink-0 relative overflow-visible" style={{ width: widthPx, height: heightPx }}>
1591 <div 1607 <div
1592 ref={canvasRef} 1608 ref={canvasRef}
1593 tabIndex={0} 1609 tabIndex={0}
1594 className={cn( 1610 className={cn(
1595 - 'relative bg-white shadow-lg origin-top-left outline-none', 1611 + 'absolute left-0 top-0 bg-white shadow-lg outline-none',
1596 canvasBorderClass, 1612 canvasBorderClass,
1597 isPanning ? 'cursor-grabbing' : 'cursor-grab' 1613 isPanning ? 'cursor-grabbing' : 'cursor-grab'
1598 )} 1614 )}
@@ -1600,6 +1616,7 @@ export function LabelCanvas({ @@ -1600,6 +1616,7 @@ export function LabelCanvas({
1600 width: baseW, 1616 width: baseW,
1601 height: baseH, 1617 height: baseH,
1602 transform: `scale(${scale})`, 1618 transform: `scale(${scale})`,
  1619 + transformOrigin: 'top left',
1603 backgroundImage: showGrid 1620 backgroundImage: showGrid
1604 ? `linear-gradient(to right, rgba(0,0,0,0.06) 1px, transparent 1px), 1621 ? `linear-gradient(to right, rgba(0,0,0,0.06) 1px, transparent 1px),
1605 linear-gradient(to bottom, rgba(0,0,0,0.06) 1px, transparent 1px)` 1622 linear-gradient(to bottom, rgba(0,0,0,0.06) 1px, transparent 1px)`
@@ -1657,18 +1674,38 @@ export function LabelCanvas({ @@ -1657,18 +1674,38 @@ export function LabelCanvas({
1657 onPointerUp={handlePointerUp} 1674 onPointerUp={handlePointerUp}
1658 onKeyDown={handleKeyDown} 1675 onKeyDown={handleKeyDown}
1659 > 1676 >
1660 - {/* 主题色虚线安全区:控件不可移出(与 LABEL_CANVAS_SAFE_MARGIN_PX 一致) */}  
1661 - <div  
1662 - className="pointer-events-none absolute z-[1] box-border rounded-sm"  
1663 - style={{  
1664 - top: LABEL_CANVAS_SAFE_MARGIN_PX,  
1665 - left: LABEL_CANVAS_SAFE_MARGIN_PX,  
1666 - right: LABEL_CANVAS_SAFE_MARGIN_PX,  
1667 - bottom: LABEL_CANVAS_SAFE_MARGIN_PX,  
1668 - border: '2px dashed var(--primary)',  
1669 - }}  
1670 - aria-hidden  
1671 - /> 1677 + {/* 选中元素对齐参考线:延伸至画布四边,蓝色虚线 */}
  1678 + {selectedId
  1679 + ? (() => {
  1680 + const el = template.elements.find((e) => e.id === selectedId);
  1681 + if (!el) return null;
  1682 + const lineCls = "pointer-events-none absolute z-[2] border-blue-600";
  1683 + return (
  1684 + <>
  1685 + <div
  1686 + className={cn(lineCls, "left-0 right-0 border-t border-dashed")}
  1687 + style={{ top: el.y }}
  1688 + aria-hidden
  1689 + />
  1690 + <div
  1691 + className={cn(lineCls, "left-0 right-0 border-t border-dashed")}
  1692 + style={{ top: el.y + el.height }}
  1693 + aria-hidden
  1694 + />
  1695 + <div
  1696 + className={cn(lineCls, "top-0 bottom-0 border-l border-dashed")}
  1697 + style={{ left: el.x }}
  1698 + aria-hidden
  1699 + />
  1700 + <div
  1701 + className={cn(lineCls, "top-0 bottom-0 border-l border-dashed")}
  1702 + style={{ left: el.x + el.width }}
  1703 + aria-hidden
  1704 + />
  1705 + </>
  1706 + );
  1707 + })()
  1708 + : null}
1672 {/* Paper resize: top */} 1709 {/* Paper resize: top */}
1673 {onTemplateChange && ( 1710 {onTemplateChange && (
1674 <div 1711 <div
@@ -1755,7 +1792,7 @@ export function LabelCanvas({ @@ -1755,7 +1792,7 @@ export function LabelCanvas({
1755 {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( 1792 {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
1756 <div 1793 <div
1757 key={corner} 1794 key={corner}
1758 - className="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-full z-20 shadow-md hover:scale-110 transition-transform" 1795 + className="absolute w-3.5 h-3.5 bg-white border-2 border-blue-600 rounded-none z-20 shadow-sm hover:scale-110 transition-transform"
1759 style={{ 1796 style={{
1760 cursor: 'nwse-resize', 1797 cursor: 'nwse-resize',
1761 top: corner.startsWith('n') ? -6 : undefined, 1798 top: corner.startsWith('n') ? -6 : undefined,
@@ -1784,7 +1821,7 @@ export function LabelCanvas({ @@ -1784,7 +1821,7 @@ export function LabelCanvas({
1784 {(['n', 's', 'w', 'e'] as const).map((edge) => ( 1821 {(['n', 's', 'w', 'e'] as const).map((edge) => (
1785 <div 1822 <div
1786 key={edge} 1823 key={edge}
1787 - className="absolute bg-blue-500/50 border border-white/50 rounded-sm z-10 shadow-sm hover:bg-blue-600" 1824 + className="absolute bg-white border-2 border-blue-600 rounded-none z-10 shadow-sm hover:bg-blue-50"
1788 style={{ 1825 style={{
1789 cursor: edge === 'n' || edge === 's' ? 'ns-resize' : 'ew-resize', 1826 cursor: edge === 'n' || edge === 's' ? 'ns-resize' : 'ew-resize',
1790 width: edge === 'n' || edge === 's' ? '20px' : '6px', 1827 width: edge === 'n' || edge === 's' ? '20px' : '6px',
@@ -1823,6 +1860,46 @@ export function LabelCanvas({ @@ -1823,6 +1860,46 @@ export function LabelCanvas({
1823 </div> 1860 </div>
1824 ); 1861 );
1825 })} 1862 })}
  1863 + {baseW > LABEL_CANVAS_SAFE_MARGIN_PX * 2 && baseH > LABEL_CANVAS_SAFE_MARGIN_PX * 2 ? (
  1864 + <svg
  1865 + className="pointer-events-none absolute left-0 top-0 z-[12]"
  1866 + width={baseW}
  1867 + height={baseH}
  1868 + style={{ overflow: "visible" }}
  1869 + aria-hidden
  1870 + >
  1871 + {(() => {
  1872 + const m = LABEL_CANVAS_SAFE_MARGIN_PX;
  1873 + const stroke = "#2563eb";
  1874 + const sw = 2;
  1875 + const dash = "10 6";
  1876 + return (
  1877 + <>
  1878 + <line x1={0} y1={m} x2={baseW} y2={m} stroke={stroke} strokeWidth={sw} strokeDasharray={dash} />
  1879 + <line
  1880 + x1={0}
  1881 + y1={baseH - m}
  1882 + x2={baseW}
  1883 + y2={baseH - m}
  1884 + stroke={stroke}
  1885 + strokeWidth={sw}
  1886 + strokeDasharray={dash}
  1887 + />
  1888 + <line x1={m} y1={0} x2={m} y2={baseH} stroke={stroke} strokeWidth={sw} strokeDasharray={dash} />
  1889 + <line
  1890 + x1={baseW - m}
  1891 + y1={0}
  1892 + x2={baseW - m}
  1893 + y2={baseH}
  1894 + stroke={stroke}
  1895 + strokeWidth={sw}
  1896 + strokeDasharray={dash}
  1897 + />
  1898 + </>
  1899 + );
  1900 + })()}
  1901 + </svg>
  1902 + ) : null}
1826 </div> 1903 </div>
1827 </div> 1904 </div>
1828 </div> 1905 </div>
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
@@ -19,7 +19,13 @@ import type { @@ -19,7 +19,13 @@ import type {
19 Border, 19 Border,
20 NutritionExtraItem, 20 NutritionExtraItem,
21 } from '../../../types/labelTemplate'; 21 } from '../../../types/labelTemplate';
22 -import { canonicalElementType, isBlankSpaceElement, NUTRITION_FIXED_ITEMS } from '../../../types/labelTemplate'; 22 +import {
  23 + canonicalElementType,
  24 + isBlankSpaceElement,
  25 + isTemplateSectionPersistedType,
  26 + NUTRITION_FIXED_ITEMS,
  27 +} from '../../../types/labelTemplate';
  28 +import { ImageUrlUpload } from '../../ui/image-url-upload';
23 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; 29 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption';
24 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; 30 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
25 import { Checkbox } from '../../ui/checkbox'; 31 import { Checkbox } from '../../ui/checkbox';
@@ -354,19 +360,26 @@ function MultipleOptionsDictionaryFields({ @@ -354,19 +360,26 @@ function MultipleOptionsDictionaryFields({
354 ); 360 );
355 } 361 }
356 362
  363 +const TEMPLATE_IMAGE_UPLOAD_BOX =
  364 + 'box-border h-[150px] w-[150px] min-h-[150px] min-w-[150px] max-h-[150px] max-w-[150px] shrink-0';
  365 +
357 function TextStaticStyleFields({ 366 function TextStaticStyleFields({
358 cfg, 367 cfg,
359 update, 368 update,
360 textAlignDefault, 369 textAlignDefault,
  370 + primaryTextLabel,
361 }: { 371 }: {
362 cfg: Record<string, unknown>; 372 cfg: Record<string, unknown>;
363 update: (key: string, value: unknown) => void; 373 update: (key: string, value: unknown) => void;
364 textAlignDefault: string; 374 textAlignDefault: string;
  375 + /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */
  376 + primaryTextLabel?: 'Text' | 'Value';
365 }) { 377 }) {
  378 + const textLabel = primaryTextLabel ?? 'Text';
366 return ( 379 return (
367 <> 380 <>
368 <div> 381 <div>
369 - <Label className="text-xs">Text</Label> 382 + <Label className="text-xs">{textLabel}</Label>
370 <Input 383 <Input
371 value={(cfg.text as string) ?? '0.00'} 384 value={(cfg.text as string) ?? '0.00'}
372 onChange={(e) => update('text', e.target.value)} 385 onChange={(e) => update('text', e.target.value)}
@@ -480,6 +493,8 @@ function ElementConfigFields({ @@ -480,6 +493,8 @@ function ElementConfigFields({
480 const elementType = canonicalElementType(element.type); 493 const elementType = canonicalElementType(element.type);
481 const update = (key: string, value: unknown) => 494 const update = (key: string, value: unknown) =>
482 onChange({ [key]: value }); 495 onChange({ [key]: value });
  496 + const fromTemplatePalette = isTemplateSectionPersistedType(element);
  497 + const staticTextLabel = fromTemplatePalette ? ('Value' as const) : ('Text' as const);
483 498
484 switch (elementType) { 499 switch (elementType) {
485 case 'TEXT_STATIC': 500 case 'TEXT_STATIC':
@@ -487,11 +502,23 @@ function ElementConfigFields({ @@ -487,11 +502,23 @@ function ElementConfigFields({
487 return ( 502 return (
488 <> 503 <>
489 <MultipleOptionsDictionaryFields cfg={cfg} onPatch={onChange} /> 504 <MultipleOptionsDictionaryFields cfg={cfg} onPatch={onChange} />
490 - <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="left" /> 505 + <TextStaticStyleFields
  506 + cfg={cfg}
  507 + update={update}
  508 + textAlignDefault="left"
  509 + primaryTextLabel={staticTextLabel}
  510 + />
491 </> 511 </>
492 ); 512 );
493 } 513 }
494 - return <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="right" />; 514 + return (
  515 + <TextStaticStyleFields
  516 + cfg={cfg}
  517 + update={update}
  518 + textAlignDefault="right"
  519 + primaryTextLabel={staticTextLabel}
  520 + />
  521 + );
495 case 'TEXT_PRODUCT': 522 case 'TEXT_PRODUCT':
496 case 'TEXT_PRICE': 523 case 'TEXT_PRICE':
497 return <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="right" />; 524 return <TextStaticStyleFields cfg={cfg} update={update} textAlignDefault="right" />;
@@ -541,7 +568,41 @@ function ElementConfigFields({ @@ -541,7 +568,41 @@ function ElementConfigFields({
541 /> 568 />
542 </div> 569 </div>
543 ); 570 );
544 - case 'IMAGE': 571 + case 'IMAGE': {
  572 + if (fromTemplatePalette) {
  573 + const src = String(cfg.src ?? '').trim();
  574 + return (
  575 + <>
  576 + <div>
  577 + <Label className="text-xs">Image</Label>
  578 + <ImageUrlUpload
  579 + value={src}
  580 + onChange={(url) => update('src', url)}
  581 + uploadSubDir="label-template-editor"
  582 + oneImageOnly
  583 + boxClassName={TEMPLATE_IMAGE_UPLOAD_BOX}
  584 + hint="Stored in template; print uses this URL (empty if cleared)."
  585 + />
  586 + </div>
  587 + <div>
  588 + <Label className="text-xs">Scale Mode</Label>
  589 + <Select
  590 + value={(cfg.scaleMode as string) ?? 'contain'}
  591 + onValueChange={(v) => update('scaleMode', v)}
  592 + >
  593 + <SelectTrigger className="h-8 text-sm mt-1">
  594 + <SelectValue />
  595 + </SelectTrigger>
  596 + <SelectContent>
  597 + <SelectItem value="contain">Contain</SelectItem>
  598 + <SelectItem value="cover">Cover</SelectItem>
  599 + <SelectItem value="fill">Fill</SelectItem>
  600 + </SelectContent>
  601 + </Select>
  602 + </div>
  603 + </>
  604 + );
  605 + }
545 return ( 606 return (
546 <> 607 <>
547 <div> 608 <div>
@@ -571,6 +632,7 @@ function ElementConfigFields({ @@ -571,6 +632,7 @@ function ElementConfigFields({
571 </div> 632 </div>
572 </> 633 </>
573 ); 634 );
  635 + }
574 case 'DATE': { 636 case 'DATE': {
575 const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase(); 637 const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase();
576 const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date'; 638 const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date';
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
@@ -608,6 +608,7 @@ export function LabelTemplateEditor({ @@ -608,6 +608,7 @@ export function LabelTemplateEditor({
608 scale={scale} 608 scale={scale}
609 onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))} 609 onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))}
610 onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))} 610 onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))}
  611 + onResetZoom={() => setScale(DEFAULT_SCALE)}
611 onPreview={() => setPreviewOpen(true)} 612 onPreview={() => setPreviewOpen(true)}
612 hideToolbarPresetSize 613 hideToolbarPresetSize
613 /> 614 />
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
@@ -74,8 +74,17 @@ import { @@ -74,8 +74,17 @@ import {
74 applyOffsetToDate, 74 applyOffsetToDate,
75 formatDateByPreset, 75 formatDateByPreset,
76 LABEL_FORM_OFFSET_UNITS, 76 LABEL_FORM_OFFSET_UNITS,
  77 + normalizeLabelFormOffsetInput,
77 serializePrintInputOffset, 78 serializePrintInputOffset,
78 } from "../../lib/labelFormDatePreview"; 79 } from "../../lib/labelFormDatePreview";
  80 +import {
  81 + listNutritionElements,
  82 + listNutritionManualFieldSpecs,
  83 + mergeNutritionManualIntoConfig,
  84 + nutritionDefaultValuesJsonForSave,
  85 + nutritionManualValuesFromTemplateConfig,
  86 + type NutritionManualFieldSpec,
  87 +} from "../../lib/nutritionManualEntry";
79 88
80 function toDisplay(v: string | null | undefined): string { 89 function toDisplay(v: string | null | undefined): string {
81 const s = (v ?? "").trim(); 90 const s = (v ?? "").trim();
@@ -141,6 +150,7 @@ function buildCreateLabelPreviewTemplate( @@ -141,6 +150,7 @@ function buildCreateLabelPreviewTemplate(
141 apiTpl: LabelTemplateDto | null, 150 apiTpl: LabelTemplateDto | null,
142 textValues: Record<string, string>, 151 textValues: Record<string, string>,
143 dateOffsets: Record<string, { unit: string; value: string }>, 152 dateOffsets: Record<string, { unit: string; value: string }>,
  153 + nutritionByElementId: Record<string, Record<string, string>>,
144 ): LabelTemplate | null { 154 ): LabelTemplate | null {
145 if (!apiTpl) return null; 155 if (!apiTpl) return null;
146 const tmpl = dtoToEditorTemplate(apiTpl); 156 const tmpl = dtoToEditorTemplate(apiTpl);
@@ -153,11 +163,12 @@ function buildCreateLabelPreviewTemplate( @@ -153,11 +163,12 @@ function buildCreateLabelPreviewTemplate(
153 const type = canonicalElementType(el.type); 163 const type = canonicalElementType(el.type);
154 if (isDateTimeDataEntryField(el)) { 164 if (isDateTimeDataEntryField(el)) {
155 const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; 165 const pair = dateOffsets[id] ?? { unit: "Days", value: "" };
156 - const amount = Number(String(pair.value).trim());  
157 const unit = pair.unit || "Days"; 166 const unit = pair.unit || "Days";
158 - if (!Number.isFinite(amount) || String(pair.value).trim() === "") { 167 + const norm = normalizeLabelFormOffsetInput(pair.value);
  168 + if (norm.kind === "invalid") {
159 cfg.__previewFormatted = ""; 169 cfg.__previewFormatted = "";
160 } else { 170 } else {
  171 + const amount = norm.kind === "zero" ? 0 : norm.amount;
161 const d = applyOffsetToDate(now, amount, unit); 172 const d = applyOffsetToDate(now, amount, unit);
162 if (type === "DATE") { 173 if (type === "DATE") {
163 const it = String(cfg.inputType ?? cfg.InputType ?? "").toLowerCase(); 174 const it = String(cfg.inputType ?? cfg.InputType ?? "").toLowerCase();
@@ -185,6 +196,12 @@ function buildCreateLabelPreviewTemplate( @@ -185,6 +196,12 @@ function buildCreateLabelPreviewTemplate(
185 } 196 }
186 el.config = cfg; 197 el.config = cfg;
187 } 198 }
  199 + for (const el of tmpl.elements) {
  200 + if (canonicalElementType(el.type) !== "NUTRITION") continue;
  201 + const manual = nutritionByElementId[el.id] ?? {};
  202 + const merged = mergeNutritionManualIntoConfig({ ...(el.config as Record<string, unknown>) }, manual);
  203 + el.config = merged as LabelElement["config"];
  204 + }
188 return tmpl; 205 return tmpl;
189 } 206 }
190 207
@@ -192,24 +209,32 @@ function collectTemplateDefaultValuesForSave( @@ -192,24 +209,32 @@ function collectTemplateDefaultValuesForSave(
192 latest: LabelTemplateDto, 209 latest: LabelTemplateDto,
193 textValues: Record<string, string>, 210 textValues: Record<string, string>,
194 dateOffsets: Record<string, { unit: string; value: string }>, 211 dateOffsets: Record<string, { unit: string; value: string }>,
  212 + nutritionByElementId: Record<string, Record<string, string>>,
195 ): Record<string, string> { 213 ): Record<string, string> {
196 const out: Record<string, string> = {}; 214 const out: Record<string, string> = {};
197 for (const el of getDataEntryElements(latest)) { 215 for (const el of getDataEntryElements(latest)) {
198 const id = el.id; 216 const id = el.id;
199 if (isDateTimeDataEntryField(el)) { 217 if (isDateTimeDataEntryField(el)) {
200 const pair = dateOffsets[id] ?? { unit: "Days", value: "" }; 218 const pair = dateOffsets[id] ?? { unit: "Days", value: "" };
201 - const amount = Number(String(pair.value).trim());  
202 const unit = pair.unit || "Days"; 219 const unit = pair.unit || "Days";
203 - if (!Number.isFinite(amount) || String(pair.value).trim() === "") { 220 + const norm = normalizeLabelFormOffsetInput(pair.value);
  221 + if (norm.kind === "invalid") {
204 out[id] = ""; 222 out[id] = "";
  223 + } else if (norm.kind === "zero") {
  224 + out[id] = serializePrintInputOffset(unit, "0");
205 } else { 225 } else {
206 - /** 落库为 JSON:App 预览/打印时按 BaseTime + 单位/数值解析,与 format 一致 */  
207 - out[id] = serializePrintInputOffset(unit, String(pair.value).trim()); 226 + out[id] = serializePrintInputOffset(unit, norm.storeValue);
208 } 227 }
209 } else { 228 } else {
210 out[id] = String(textValues[id] ?? ""); 229 out[id] = String(textValues[id] ?? "");
211 } 230 }
212 } 231 }
  232 + for (const nel of listNutritionElements((latest.elements ?? []) as LabelElement[])) {
  233 + const manual = nutritionByElementId[nel.id];
  234 + if (!manual) continue;
  235 + const j = nutritionDefaultValuesJsonForSave(manual);
  236 + if (j) out[nel.id] = j;
  237 + }
213 return out; 238 return out;
214 } 239 }
215 240
@@ -957,6 +982,8 @@ function CreateLabelDialog({ @@ -957,6 +982,8 @@ function CreateLabelDialog({
957 const [templateDateOffsets, setTemplateDateOffsets] = useState< 982 const [templateDateOffsets, setTemplateDateOffsets] = useState<
958 Record<string, { unit: string; value: string }> 983 Record<string, { unit: string; value: string }>
959 >({}); 984 >({});
  985 + /** NUTRITION 元素 id → 子字段(calories、fat、extra:…)手动值 */
  986 + const [nutritionByElementId, setNutritionByElementId] = useState<Record<string, Record<string, string>>>({});
960 const [form, setForm] = useState<LabelCreateInput>({ 987 const [form, setForm] = useState<LabelCreateInput>({
961 labelCode: "", 988 labelCode: "",
962 labelName: "", 989 labelName: "",
@@ -984,6 +1011,7 @@ function CreateLabelDialog({ @@ -984,6 +1011,7 @@ function CreateLabelDialog({
984 setSelectedTemplate(null); 1011 setSelectedTemplate(null);
985 setTemplateDataValues({}); 1012 setTemplateDataValues({});
986 setTemplateDateOffsets({}); 1013 setTemplateDateOffsets({});
  1014 + setNutritionByElementId({});
987 setProductCatalogCategoryId(""); 1015 setProductCatalogCategoryId("");
988 }; 1016 };
989 1017
@@ -1000,6 +1028,7 @@ function CreateLabelDialog({ @@ -1000,6 +1028,7 @@ function CreateLabelDialog({
1000 setSelectedTemplate(null); 1028 setSelectedTemplate(null);
1001 setTemplateDataValues({}); 1029 setTemplateDataValues({});
1002 setTemplateDateOffsets({}); 1030 setTemplateDateOffsets({});
  1031 + setNutritionByElementId({});
1003 return; 1032 return;
1004 } 1033 }
1005 let cancelled = false; 1034 let cancelled = false;
@@ -1020,11 +1049,18 @@ function CreateLabelDialog({ @@ -1020,11 +1049,18 @@ function CreateLabelDialog({
1020 } 1049 }
1021 setTemplateDataValues(nextValues); 1050 setTemplateDataValues(nextValues);
1022 setTemplateDateOffsets(nextOffsets); 1051 setTemplateDateOffsets(nextOffsets);
  1052 + const nuts = listNutritionElements((tpl.elements ?? []) as LabelElement[]);
  1053 + const nextNut: Record<string, Record<string, string>> = {};
  1054 + for (const n of nuts) {
  1055 + nextNut[n.id] = nutritionManualValuesFromTemplateConfig(n);
  1056 + }
  1057 + setNutritionByElementId(nextNut);
1023 } catch (e: any) { 1058 } catch (e: any) {
1024 if (cancelled) return; 1059 if (cancelled) return;
1025 setSelectedTemplate(null); 1060 setSelectedTemplate(null);
1026 setTemplateDataValues({}); 1061 setTemplateDataValues({});
1027 setTemplateDateOffsets({}); 1062 setTemplateDateOffsets({});
  1063 + setNutritionByElementId({});
1028 toast.error("Failed to load template fields.", { 1064 toast.error("Failed to load template fields.", {
1029 description: e?.message ? String(e.message) : "Please select another template.", 1065 description: e?.message ? String(e.message) : "Please select another template.",
1030 }); 1066 });
@@ -1058,12 +1094,16 @@ function CreateLabelDialog({ @@ -1058,12 +1094,16 @@ function CreateLabelDialog({
1058 const labelTypeId = form.labelTypeId.trim(); 1094 const labelTypeId = form.labelTypeId.trim();
1059 if (!labelTypeId) return; 1095 if (!labelTypeId) return;
1060 const latest = await getLabelTemplate(code); 1096 const latest = await getLabelTemplate(code);
1061 - if (getDataEntryElements(latest).length === 0) return; 1097 + const dataEls = getDataEntryElements(latest);
  1098 + const hasNutritionRows =
  1099 + listNutritionElements((latest.elements ?? []) as LabelElement[]).length > 0;
  1100 + if (dataEls.length === 0 && !hasNutritionRows) return;
1062 1101
1063 const inputDefaultValues = collectTemplateDefaultValuesForSave( 1102 const inputDefaultValues = collectTemplateDefaultValuesForSave(
1064 latest, 1103 latest,
1065 templateDataValues, 1104 templateDataValues,
1066 templateDateOffsets, 1105 templateDateOffsets,
  1106 + nutritionByElementId,
1067 ); 1107 );
1068 const defaultsMap = buildTemplateDefaultsMap(latest); 1108 const defaultsMap = buildTemplateDefaultsMap(latest);
1069 for (const productId of form.productIds) { 1109 for (const productId of form.productIds) {
@@ -1165,39 +1205,77 @@ function CreateLabelDialog({ @@ -1165,39 +1205,77 @@ function CreateLabelDialog({
1165 [selectedTemplate], 1205 [selectedTemplate],
1166 ); 1206 );
1167 1207
  1208 + const nutritionFieldBlocks = useMemo(() => {
  1209 + if (!selectedTemplate) return [] as Array<{ el: LabelElement; spec: NutritionManualFieldSpec }>;
  1210 + const out: Array<{ el: LabelElement; spec: NutritionManualFieldSpec }> = [];
  1211 + for (const nel of listNutritionElements((selectedTemplate.elements ?? []) as LabelElement[])) {
  1212 + for (const spec of listNutritionManualFieldSpecs(nel)) {
  1213 + out.push({ el: nel, spec });
  1214 + }
  1215 + }
  1216 + return out;
  1217 + }, [selectedTemplate]);
  1218 +
  1219 + const showTemplateInputColumn =
  1220 + dataEntryElements.length > 0 || nutritionFieldBlocks.length > 0;
  1221 +
1168 const previewTemplate = useMemo( 1222 const previewTemplate = useMemo(
1169 - () => buildCreateLabelPreviewTemplate(selectedTemplate, templateDataValues, templateDateOffsets),  
1170 - [selectedTemplate, templateDataValues, templateDateOffsets], 1223 + () =>
  1224 + buildCreateLabelPreviewTemplate(
  1225 + selectedTemplate,
  1226 + templateDataValues,
  1227 + templateDateOffsets,
  1228 + nutritionByElementId,
  1229 + ),
  1230 + [selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId],
1171 ); 1231 );
1172 const hasTemplateSelected = form.templateCode.trim().length > 0; 1232 const hasTemplateSelected = form.templateCode.trim().length > 0;
1173 1233
1174 return ( 1234 return (
1175 <Dialog open={open} onOpenChange={onOpenChange}> 1235 <Dialog open={open} onOpenChange={onOpenChange}>
1176 <DialogContent 1236 <DialogContent
1177 - className="overflow-hidden max-w-none" 1237 + className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden max-w-none gap-4 !top-5 !translate-y-0"
1178 style={{ 1238 style={{
1179 width: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", 1239 width: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)",
1180 maxWidth: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)", 1240 maxWidth: hasTemplateSelected ? "calc(100vw - 3rem)" : "min(96vw, 780px)",
1181 - maxHeight: "86vh",  
1182 }} 1241 }}
1183 > 1242 >
1184 - <DialogHeader> 1243 + <DialogHeader className="shrink-0">
1185 <DialogTitle>Add New Label</DialogTitle> 1244 <DialogTitle>Add New Label</DialogTitle>
1186 <DialogDescription>Enter the details for the new label.</DialogDescription> 1245 <DialogDescription>Enter the details for the new label.</DialogDescription>
1187 </DialogHeader> 1246 </DialogHeader>
1188 1247
1189 - <div className="min-h-0 overflow-y-auto overflow-x-hidden py-2"> 1248 + <div
  1249 + className={hasTemplateSelected ? "min-h-0 flex-none overflow-hidden py-2" : "min-h-0 flex-1 overflow-hidden py-2"}
  1250 + style={
  1251 + hasTemplateSelected
  1252 + ? {
  1253 + height: "min(72vh, calc(100dvh - 10.5rem))",
  1254 + maxHeight: "min(72vh, calc(100dvh - 10.5rem))",
  1255 + }
  1256 + : undefined
  1257 + }
  1258 + >
1190 <div 1259 <div
1191 - className="grid gap-3 min-w-0 items-start" 1260 + className="grid h-full min-h-0 min-w-0 gap-3 items-stretch"
1192 style={ 1261 style={
1193 hasTemplateSelected 1262 hasTemplateSelected
1194 - ? {  
1195 - gridTemplateColumns: "minmax(0, 1fr) minmax(0, 12.5rem) minmax(0, 1.55fr)", 1263 + ? showTemplateInputColumn
  1264 + ? {
  1265 + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 12.5rem) minmax(0, 1.55fr)",
  1266 + gridTemplateRows: "minmax(0, 1fr)",
  1267 + }
  1268 + : {
  1269 + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.55fr)",
  1270 + gridTemplateRows: "minmax(0, 1fr)",
  1271 + }
  1272 + : {
  1273 + gridTemplateColumns: "minmax(0, 1fr)",
  1274 + gridTemplateRows: "minmax(0, 1fr)",
1196 } 1275 }
1197 - : { gridTemplateColumns: "minmax(0, 1fr)" }  
1198 } 1276 }
1199 > 1277 >
1200 - <div className="min-w-0 rounded-lg border bg-gray-50 p-4 overflow-y-auto overflow-x-hidden w-full"> 1278 + <div className="min-h-0 min-w-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-gray-50 p-4 [scrollbar-gutter:stable]">
1201 <div className="text-sm font-semibold text-gray-900">General Settings</div> 1279 <div className="text-sm font-semibold text-gray-900">General Settings</div>
1202 <div className="space-y-2 mt-3 mb-2"> 1280 <div className="space-y-2 mt-3 mb-2">
1203 <ProductSingleSelectByCategoryField 1281 <ProductSingleSelectByCategoryField
@@ -1289,15 +1367,13 @@ function CreateLabelDialog({ @@ -1289,15 +1367,13 @@ function CreateLabelDialog({
1289 </div> 1367 </div>
1290 </div> 1368 </div>
1291 1369
1292 - {hasTemplateSelected ? (  
1293 - <div className="min-w-0 w-full max-w-full box-border rounded-lg border bg-gray-50 p-4 overflow-y-auto overflow-x-hidden"> 1370 + {hasTemplateSelected && showTemplateInputColumn ? (
  1371 + <div className="box-border min-h-0 min-w-0 w-full max-w-full overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-gray-50 p-4 [scrollbar-gutter:stable]">
1294 <div className="text-sm font-semibold text-gray-900 mb-3">Template Input Data</div> 1372 <div className="text-sm font-semibold text-gray-900 mb-3">Template Input Data</div>
1295 {templateLoading ? ( 1373 {templateLoading ? (
1296 <div className="text-sm text-gray-500">Loading template fields...</div> 1374 <div className="text-sm text-gray-500">Loading template fields...</div>
1297 ) : !form.templateCode.trim() ? ( 1375 ) : !form.templateCode.trim() ? (
1298 <div className="text-sm text-gray-500">Select template first to load input fields.</div> 1376 <div className="text-sm text-gray-500">Select template first to load input fields.</div>
1299 - ) : dataEntryElements.length === 0 ? (  
1300 - <div className="text-sm text-gray-500">No manual input fields in this template.</div>  
1301 ) : ( 1377 ) : (
1302 <div className="space-y-3"> 1378 <div className="space-y-3">
1303 {dataEntryElements.map((el) => ( 1379 {dataEntryElements.map((el) => (
@@ -1353,9 +1429,35 @@ function CreateLabelDialog({ @@ -1353,9 +1429,35 @@ function CreateLabelDialog({
1353 )} 1429 )}
1354 </div> 1430 </div>
1355 ))} 1431 ))}
  1432 + {nutritionFieldBlocks.length > 0 ? (
  1433 + <div className="pt-2 mt-2 border-t border-gray-200 space-y-3">
  1434 + <div className="text-xs font-semibold text-gray-700">Nutrition Facts (manual)</div>
  1435 + {nutritionFieldBlocks.map(({ el: nel, spec }) => (
  1436 + <div key={`${nel.id}-${spec.subKey}`} className="space-y-1.5 w-full min-w-0">
  1437 + <Label className="block">{spec.columnLabel}</Label>
  1438 + <Input
  1439 + className="h-10 w-full min-w-0 box-border"
  1440 + value={nutritionByElementId[nel.id]?.[spec.subKey] ?? ""}
  1441 + onChange={(e) =>
  1442 + setNutritionByElementId((prev) => ({
  1443 + ...prev,
  1444 + [nel.id]: {
  1445 + ...(prev[nel.id] ?? {}),
  1446 + [spec.subKey]: e.target.value,
  1447 + },
  1448 + }))
  1449 + }
  1450 + placeholder={`Enter ${spec.columnLabel}`}
  1451 + />
  1452 + </div>
  1453 + ))}
  1454 + </div>
  1455 + ) : null}
1356 <div className="text-xs text-gray-500 pt-1 w-full min-w-0 break-words"> 1456 <div className="text-xs text-gray-500 pt-1 w-full min-w-0 break-words">
1357 - Date/time fields: preview uses current time plus offset; format follows each field&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 </div> 1461 </div>
1360 </div> 1462 </div>
1361 )} 1463 )}
@@ -1363,10 +1465,7 @@ function CreateLabelDialog({ @@ -1363,10 +1465,7 @@ function CreateLabelDialog({
1363 ) : null} 1465 ) : null}
1364 1466
1365 {hasTemplateSelected ? ( 1467 {hasTemplateSelected ? (
1366 - <div  
1367 - className="min-w-0 w-full rounded-lg border bg-gray-50 p-4 overflow-y-auto overflow-x-hidden"  
1368 - style={{ minHeight: 320 }}  
1369 - > 1468 + <div className="min-h-0 min-w-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-gray-50 p-4 [scrollbar-gutter:stable]">
1370 <div className="text-sm font-semibold text-gray-900 mb-3">Label Preview</div> 1469 <div className="text-sm font-semibold text-gray-900 mb-3">Label Preview</div>
1371 {previewTemplate ? ( 1470 {previewTemplate ? (
1372 <div className="flex justify-center w-full min-w-0 overflow-hidden"> 1471 <div className="flex justify-center w-full min-w-0 overflow-hidden">
@@ -1380,7 +1479,7 @@ function CreateLabelDialog({ @@ -1380,7 +1479,7 @@ function CreateLabelDialog({
1380 </div> 1479 </div>
1381 </div> 1480 </div>
1382 1481
1383 - <DialogFooter> 1482 + <DialogFooter className="shrink-0">
1384 <Button variant="outline" onClick={() => onOpenChange(false)}> 1483 <Button variant="outline" onClick={() => onOpenChange(false)}>
1385 Cancel 1484 Cancel
1386 </Button> 1485 </Button>
美国版/Food Labeling Management Platform/src/components/labels/LabelsView.tsx
1 -import React, { useState } from 'react'; 1 +import React, { useCallback, useState } from 'react';
2 import { LabelsList } from './LabelsList'; 2 import { LabelsList } from './LabelsList';
3 import { LabelCategoriesView } from './LabelCategoriesView'; 3 import { LabelCategoriesView } from './LabelCategoriesView';
4 import { LabelTypesView } from './LabelTypesView'; 4 import { LabelTypesView } from './LabelTypesView';
@@ -13,6 +13,8 @@ interface LabelsViewProps { @@ -13,6 +13,8 @@ interface LabelsViewProps {
13 /** Dashboard「New Label」递增;由 Labels 列表消费后应调用 onLabelCreateIntentConsumed */ 13 /** Dashboard「New Label」递增;由 Labels 列表消费后应调用 onLabelCreateIntentConsumed */
14 labelCreateOpenSeq?: number; 14 labelCreateOpenSeq?: number;
15 onLabelCreateIntentConsumed?: () => void; 15 onLabelCreateIntentConsumed?: () => void;
  16 + /** 标签模板新增/编辑时 true,用于布局层隐藏侧栏与顶栏 */
  17 + onLabelTemplateEditorLayoutOverlay?: (fullscreen: boolean) => void;
16 } 18 }
17 19
18 export function LabelsView({ 20 export function LabelsView({
@@ -20,9 +22,18 @@ export function LabelsView({ @@ -20,9 +22,18 @@ export function LabelsView({
20 onViewChange, 22 onViewChange,
21 labelCreateOpenSeq = 0, 23 labelCreateOpenSeq = 0,
22 onLabelCreateIntentConsumed, 24 onLabelCreateIntentConsumed,
  25 + onLabelTemplateEditorLayoutOverlay,
23 }: LabelsViewProps) { 26 }: LabelsViewProps) {
24 const [templateEditorHidesTabs, setTemplateEditorHidesTabs] = useState(false); 27 const [templateEditorHidesTabs, setTemplateEditorHidesTabs] = useState(false);
25 28
  29 + const handleTemplateEditorOverlay = useCallback(
  30 + (fullscreen: boolean) => {
  31 + setTemplateEditorHidesTabs(fullscreen);
  32 + onLabelTemplateEditorLayoutOverlay?.(fullscreen);
  33 + },
  34 + [onLabelTemplateEditorLayoutOverlay],
  35 + );
  36 +
26 const tabs: Tab[] = [ 37 const tabs: Tab[] = [
27 'Labels', 38 'Labels',
28 'Label Categories', 39 'Label Categories',
@@ -89,7 +100,7 @@ export function LabelsView({ @@ -89,7 +100,7 @@ export function LabelsView({
89 )} 100 )}
90 {currentView === 'Label Templates' && ( 101 {currentView === 'Label Templates' && (
91 <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> 102 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
92 - <LabelTemplatesView onTemplateEditorOverlayChange={setTemplateEditorHidesTabs} /> 103 + <LabelTemplatesView onTemplateEditorOverlayChange={handleTemplateEditorOverlay} />
93 </div> 104 </div>
94 )} 105 )}
95 {currentView === 'Multiple Options' && ( 106 {currentView === 'Multiple Options' && (
美国版/Food Labeling Management Platform/src/components/layout/Layout.tsx
@@ -10,31 +10,50 @@ interface LayoutProps { @@ -10,31 +10,50 @@ interface LayoutProps {
10 setCurrentView: (view: string) => void; 10 setCurrentView: (view: string) => void;
11 menus?: CurrentUserMenuNodeDto[]; 11 menus?: CurrentUserMenuNodeDto[];
12 onLogout?: () => void; 12 onLogout?: () => void;
  13 + /** 标签模板编辑器全屏:隐藏侧栏与顶栏,主内容占满视口 */
  14 + hideAppChrome?: boolean;
13 } 15 }
14 16
15 -export function Layout({ children, currentView, setCurrentView, menus, onLogout }: LayoutProps) { 17 +export function Layout({
  18 + children,
  19 + currentView,
  20 + setCurrentView,
  21 + menus,
  22 + onLogout,
  23 + hideAppChrome = false,
  24 +}: LayoutProps) {
16 return ( 25 return (
17 <div className="flex h-screen bg-gray-50 overflow-hidden font-sans"> 26 <div className="flex h-screen bg-gray-50 overflow-hidden font-sans">
18 - <Sidebar currentView={currentView} setCurrentView={setCurrentView} menus={menus} onLogout={onLogout} />  
19 - <div className="flex-1 flex flex-col min-w-0 overflow-hidden">  
20 - <Header title={currentView} onSettingsClick={() => setCurrentView('Support')} />  
21 - <div className="px-8 mt-8 shrink-0">  
22 - <nav className="flex items-center gap-2 text-sm font-normal" aria-label="Breadcrumb">  
23 - <button  
24 - type="button"  
25 - onClick={() => setCurrentView('Dashboard')}  
26 - className="text-gray-500 hover:text-gray-700 transition-colors"  
27 - >  
28 - Home  
29 - </button>  
30 - <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />  
31 - <span style={{ color: 'rgb(43, 50, 143)' }}>{currentView}</span>  
32 - </nav>  
33 - </div>  
34 - <main className="min-h-0 flex-1 overflow-y-auto p-8">  
35 - <div className="h-full min-h-0 w-full">  
36 - {children}  
37 - </div> 27 + {!hideAppChrome && (
  28 + <Sidebar currentView={currentView} setCurrentView={setCurrentView} menus={menus} onLogout={onLogout} />
  29 + )}
  30 + <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
  31 + {!hideAppChrome && (
  32 + <>
  33 + <Header title={currentView} onSettingsClick={() => setCurrentView('Support')} />
  34 + <div className="mt-8 shrink-0 px-8">
  35 + <nav className="flex items-center gap-2 text-sm font-normal" aria-label="Breadcrumb">
  36 + <button
  37 + type="button"
  38 + onClick={() => setCurrentView('Dashboard')}
  39 + className="text-gray-500 transition-colors hover:text-gray-700"
  40 + >
  41 + Home
  42 + </button>
  43 + <ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
  44 + <span style={{ color: 'rgb(43, 50, 143)' }}>{currentView}</span>
  45 + </nav>
  46 + </div>
  47 + </>
  48 + )}
  49 + <main
  50 + className={
  51 + hideAppChrome
  52 + ? 'flex min-h-0 flex-1 flex-col overflow-hidden p-0'
  53 + : 'min-h-0 flex-1 overflow-y-auto p-8'
  54 + }
  55 + >
  56 + <div className="flex h-full min-h-0 w-full flex-col">{children}</div>
38 </main> 57 </main>
39 </div> 58 </div>
40 </div> 59 </div>
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
1 import React, { useEffect, useMemo, useRef, useState } from "react"; 1 import React, { useEffect, useMemo, useRef, useState } from "react";
2 import { Edit, MapPin, MoreHorizontal, Trash2 } from "lucide-react"; 2 import { Edit, MapPin, MoreHorizontal, Trash2 } from "lucide-react";
3 import { Button } from "../ui/button"; 3 import { Button } from "../ui/button";
  4 +import { Checkbox } from "../ui/checkbox";
4 import { Input } from "../ui/input"; 5 import { Input } from "../ui/input";
5 import { 6 import {
6 Table, 7 Table,
@@ -30,7 +31,6 @@ import { Badge } from &quot;../ui/badge&quot;; @@ -30,7 +31,6 @@ import { Badge } from &quot;../ui/badge&quot;;
30 import { Switch } from "../ui/switch"; 31 import { Switch } from "../ui/switch";
31 import { toast } from "sonner"; 32 import { toast } from "sonner";
32 import { skipCountForPage } from "../../lib/paginationQuery"; 33 import { skipCountForPage } from "../../lib/paginationQuery";
33 -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";  
34 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 34 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
35 import { 35 import {
36 Pagination, 36 Pagination,
@@ -40,12 +40,22 @@ import { @@ -40,12 +40,22 @@ import {
40 PaginationNext, 40 PaginationNext,
41 PaginationPrevious, 41 PaginationPrevious,
42 } from "../ui/pagination"; 42 } from "../ui/pagination";
43 -import { createLocation, deleteLocation, getLocations, updateLocation } from "../../services/locationService"; 43 +import {
  44 + createLocation,
  45 + deleteLocation,
  46 + downloadLocationImportTemplate,
  47 + exportLocationsExcel,
  48 + getLocations,
  49 + importLocationsBatch,
  50 + updateLocation,
  51 +} from "../../services/locationService";
44 import { getPartners } from "../../services/partnerService"; 52 import { getPartners } from "../../services/partnerService";
45 import { getGroups } from "../../services/groupService"; 53 import { getGroups } from "../../services/groupService";
46 import type { LocationCreateInput, LocationDto } from "../../types/location"; 54 import type { LocationCreateInput, LocationDto } from "../../types/location";
47 import type { GroupListItem } from "../../types/group"; 55 import type { GroupListItem } from "../../types/group";
48 import type { PartnerListItem } from "../../types/partner"; 56 import type { PartnerListItem } from "../../types/partner";
  57 +import { BatchImportDialog } from "../bulk/batch-import-dialog";
  58 +import { LocationBulkEditDialog } from "./location-bulk-edit-dialog";
49 59
50 const LOCATION_PG_NONE = "__none__"; 60 const LOCATION_PG_NONE = "__none__";
51 61
@@ -137,6 +147,12 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -137,6 +147,12 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
137 const [total, setTotal] = useState(0); 147 const [total, setTotal] = useState(0);
138 const [refreshSeq, setRefreshSeq] = useState(0); 148 const [refreshSeq, setRefreshSeq] = useState(0);
139 const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); 149 const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null);
  150 + const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
  151 + const [bulkImportOpen, setBulkImportOpen] = useState(false);
  152 + const [bulkEditOpen, setBulkEditOpen] = useState(false);
  153 + const [bulkEditSeed, setBulkEditSeed] = useState<LocationDto[]>([]);
  154 + const [tmplDownloading, setTmplDownloading] = useState(false);
  155 + const [excelExporting, setExcelExporting] = useState(false);
140 156
141 const [keyword, setKeyword] = useState(""); 157 const [keyword, setKeyword] = useState("");
142 const [partner, setPartner] = useState<string>("all"); 158 const [partner, setPartner] = useState<string>("all");
@@ -158,6 +174,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -158,6 +174,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
158 }; 174 };
159 }, [keyword]); 175 }, [keyword]);
160 176
  177 + const listKeyword = useMemo(
  178 + () => (locationPick !== "all" ? locationPick : debouncedKeyword.trim()),
  179 + [locationPick, debouncedKeyword],
  180 + );
  181 +
161 // Options derived from current result set (no dedicated endpoints provided in doc). 182 // Options derived from current result set (no dedicated endpoints provided in doc).
162 const partnerOptions = useMemo(() => { 183 const partnerOptions = useMemo(() => {
163 const s = new Set<string>(); 184 const s = new Set<string>();
@@ -203,12 +224,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -203,12 +224,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
203 setLoading(true); 224 setLoading(true);
204 try { 225 try {
205 const skipCount = skipCountForPage(pageIndex); 226 const skipCount = skipCountForPage(pageIndex);
206 - const effectiveKeyword = locationPick !== "all" ? locationPick : debouncedKeyword;  
207 const res = await getLocations( 227 const res = await getLocations(
208 { 228 {
209 skipCount, 229 skipCount,
210 maxResultCount: pageSize, 230 maxResultCount: pageSize,
211 - keyword: effectiveKeyword || undefined, 231 + keyword: listKeyword || undefined,
212 partner: partner !== "all" ? partner : undefined, 232 partner: partner !== "all" ? partner : undefined,
213 groupName: groupName !== "all" ? groupName : undefined, 233 groupName: groupName !== "all" ? groupName : undefined,
214 }, 234 },
@@ -231,7 +251,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -231,7 +251,11 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
231 251
232 run(); 252 run();
233 return () => abortRef.current?.abort(); 253 return () => abortRef.current?.abort();
234 - }, [debouncedKeyword, partner, groupName, locationPick, pageIndex, pageSize, refreshSeq]); 254 + }, [listKeyword, partner, groupName, locationPick, pageIndex, pageSize, refreshSeq]);
  255 +
  256 + useEffect(() => {
  257 + setSelectedIds(new Set());
  258 + }, [debouncedKeyword, partner, groupName, locationPick, pageIndex]);
235 259
236 const refreshList = () => setRefreshSeq((x) => x + 1); 260 const refreshList = () => setRefreshSeq((x) => x + 1);
237 261
@@ -295,36 +319,54 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -295,36 +319,54 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
295 </SelectContent> 319 </SelectContent>
296 </Select> 320 </Select>
297 <div className="flex-1" /> 321 <div className="flex-1" />
298 - <Tooltip>  
299 - <TooltipTrigger asChild>  
300 - <span>  
301 - <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">  
302 - Bulk Import  
303 - </Button>  
304 - </span>  
305 - </TooltipTrigger>  
306 - <TooltipContent>Not supported yet</TooltipContent>  
307 - </Tooltip>  
308 - <Tooltip>  
309 - <TooltipTrigger asChild>  
310 - <span>  
311 - <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">  
312 - Bulk Export  
313 - </Button>  
314 - </span>  
315 - </TooltipTrigger>  
316 - <TooltipContent>Not supported yet</TooltipContent>  
317 - </Tooltip>  
318 - <Tooltip>  
319 - <TooltipTrigger asChild>  
320 - <span>  
321 - <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">  
322 - Bulk Edit  
323 - </Button>  
324 - </span>  
325 - </TooltipTrigger>  
326 - <TooltipContent>Not supported yet</TooltipContent>  
327 - </Tooltip> 322 + <Button
  323 + type="button"
  324 + variant="outline"
  325 + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"
  326 + onClick={() => setBulkImportOpen(true)}
  327 + >
  328 + Bulk Import
  329 + </Button>
  330 + <Button
  331 + type="button"
  332 + variant="outline"
  333 + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"
  334 + disabled={excelExporting}
  335 + onClick={async () => {
  336 + setExcelExporting(true);
  337 + try {
  338 + await exportLocationsExcel({
  339 + keyword: listKeyword || undefined,
  340 + partner: partner !== "all" ? partner : undefined,
  341 + groupName: groupName !== "all" ? groupName : undefined,
  342 + });
  343 + toast.success("Export started", { description: "Your browser should download the Excel file." });
  344 + } catch (e: unknown) {
  345 + const msg = e instanceof Error ? e.message : "Please try again.";
  346 + toast.error("Export failed", { description: msg });
  347 + } finally {
  348 + setExcelExporting(false);
  349 + }
  350 + }}
  351 + >
  352 + {excelExporting ? "Exporting…" : "Bulk Export"}
  353 + </Button>
  354 + <Button
  355 + type="button"
  356 + variant="outline"
  357 + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"
  358 + onClick={() => {
  359 + const seed = locations.filter((l) => selectedIds.has(l.id));
  360 + if (seed.length === 0) {
  361 + toast.error("No rows selected", { description: "Use the checkboxes on the left, then open Bulk Edit." });
  362 + return;
  363 + }
  364 + setBulkEditSeed(seed);
  365 + setBulkEditOpen(true);
  366 + }}
  367 + >
  368 + Bulk Edit
  369 + </Button>
328 <Button 370 <Button
329 className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" 371 className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0"
330 onClick={() => setIsCreateDialogOpen(true)} 372 onClick={() => setIsCreateDialogOpen(true)}
@@ -343,6 +385,16 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -343,6 +385,16 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
343 <Table> 385 <Table>
344 <TableHeader> 386 <TableHeader>
345 <TableRow className="bg-gray-100 hover:bg-gray-100"> 387 <TableRow className="bg-gray-100 hover:bg-gray-100">
  388 + <TableHead className="text-gray-900 font-bold border-r w-12 shrink-0 text-center pl-2 pr-4">
  389 + <Checkbox
  390 + checked={locations.length > 0 && locations.every((l) => selectedIds.has(l.id))}
  391 + onCheckedChange={(c) => {
  392 + if (c === true) setSelectedIds(new Set(locations.map((l) => l.id)));
  393 + else setSelectedIds(new Set());
  394 + }}
  395 + aria-label="Select all on page"
  396 + />
  397 + </TableHead>
346 <TableHead className="text-gray-900 font-bold border-r">Company</TableHead> 398 <TableHead className="text-gray-900 font-bold border-r">Company</TableHead>
347 <TableHead className="text-gray-900 font-bold border-r">Region</TableHead> 399 <TableHead className="text-gray-900 font-bold border-r">Region</TableHead>
348 <TableHead className="text-gray-900 font-bold border-r">Location ID</TableHead> 400 <TableHead className="text-gray-900 font-bold border-r">Location ID</TableHead>
@@ -362,19 +414,33 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -362,19 +414,33 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
362 <TableBody> 414 <TableBody>
363 {loading ? ( 415 {loading ? (
364 <TableRow> 416 <TableRow>
365 - <TableCell colSpan={14} className="text-center text-sm text-gray-500 py-10"> 417 + <TableCell colSpan={15} className="text-center text-sm text-gray-500 py-10">
366 Loading... 418 Loading...
367 </TableCell> 419 </TableCell>
368 </TableRow> 420 </TableRow>
369 ) : locations.length === 0 ? ( 421 ) : locations.length === 0 ? (
370 <TableRow> 422 <TableRow>
371 - <TableCell colSpan={14} className="text-center text-sm text-gray-500 py-10"> 423 + <TableCell colSpan={15} className="text-center text-sm text-gray-500 py-10">
372 No results. 424 No results.
373 </TableCell> 425 </TableCell>
374 </TableRow> 426 </TableRow>
375 ) : ( 427 ) : (
376 locations.map((loc) => ( 428 locations.map((loc) => (
377 <TableRow key={loc.id}> 429 <TableRow key={loc.id}>
  430 + <TableCell className="border-r w-12 shrink-0 text-center pl-2 pr-4">
  431 + <Checkbox
  432 + checked={selectedIds.has(loc.id)}
  433 + onCheckedChange={(c) => {
  434 + setSelectedIds((prev) => {
  435 + const n = new Set(prev);
  436 + if (c === true) n.add(loc.id);
  437 + else n.delete(loc.id);
  438 + return n;
  439 + });
  440 + }}
  441 + aria-label="Select row"
  442 + />
  443 + </TableCell>
378 <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.partner)}</TableCell> 444 <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.partner)}</TableCell>
379 <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.groupName)}</TableCell> 445 <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.groupName)}</TableCell>
380 <TableCell className="border-r font-numeric text-gray-600">{toDisplay(loc.locationCode ?? loc.id)}</TableCell> 446 <TableCell className="border-r font-numeric text-gray-600">{toDisplay(loc.locationCode ?? loc.id)}</TableCell>
@@ -537,6 +603,41 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -537,6 +603,41 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
537 refreshList(); 603 refreshList();
538 }} 604 }}
539 /> 605 />
  606 +
  607 + <BatchImportDialog
  608 + open={bulkImportOpen}
  609 + onOpenChange={setBulkImportOpen}
  610 + title="Bulk import locations"
  611 + description="Upload an .xlsx file. Use the official template for correct column headers."
  612 + downloadingTemplate={tmplDownloading}
  613 + onDownloadTemplate={async () => {
  614 + setTmplDownloading(true);
  615 + try {
  616 + await downloadLocationImportTemplate();
  617 + toast.success("Template downloaded.");
  618 + } catch (e: unknown) {
  619 + const msg = e instanceof Error ? e.message : "Download failed.";
  620 + toast.error("Template download failed", { description: msg });
  621 + } finally {
  622 + setTmplDownloading(false);
  623 + }
  624 + }}
  625 + onImportFile={async (file) => {
  626 + const r = await importLocationsBatch(file);
  627 + refreshList();
  628 + return { successCount: r.successCount, failCount: r.failCount };
  629 + }}
  630 + />
  631 +
  632 + <LocationBulkEditDialog
  633 + open={bulkEditOpen}
  634 + onOpenChange={setBulkEditOpen}
  635 + seed={bulkEditSeed}
  636 + onSaved={() => {
  637 + setSelectedIds(new Set());
  638 + refreshList();
  639 + }}
  640 + />
540 </> 641 </>
541 ); 642 );
542 643
美国版/Food Labeling Management Platform/src/components/locations/location-bulk-edit-dialog.tsx 0 → 100644
  1 +import React, { useEffect, useMemo, useState } from "react";
  2 +import { Button } from "../ui/button";
  3 +import { Input } from "../ui/input";
  4 +import { Switch } from "../ui/switch";
  5 +import {
  6 + Dialog,
  7 + DialogContent,
  8 + DialogDescription,
  9 + DialogHeader,
  10 + DialogTitle,
  11 +} from "../ui/dialog";
  12 +import { toast } from "sonner";
  13 +import { ApiError } from "../../lib/apiClient";
  14 +import { updateLocationsBulk, type LocationBulkUpdateItemVo } from "../../services/locationService";
  15 +import type { LocationDto } from "../../types/location";
  16 +
  17 +const ZERO = "00000000-0000-0000-0000-000000000000";
  18 +
  19 +function isValidBulkId(id: string): boolean {
  20 + const s = (id ?? "").trim();
  21 + if (!s) return false;
  22 + return s.toLowerCase() !== ZERO;
  23 +}
  24 +
  25 +export type LocationBulkEditDialogProps = {
  26 + open: boolean;
  27 + onOpenChange: (open: boolean) => void;
  28 + /** 从列表勾选带入的行 */
  29 + seed: LocationDto[];
  30 + onSaved: () => void;
  31 +};
  32 +
  33 +type RowState = LocationBulkUpdateItemVo & { locationCodeReadonly: string };
  34 +
  35 +function locToRow(loc: LocationDto): RowState {
  36 + return {
  37 + id: loc.id,
  38 + locationCodeReadonly: (loc.locationCode ?? loc.id ?? "").trim(),
  39 + partner: loc.partner ?? "",
  40 + groupName: loc.groupName ?? "",
  41 + locationName: (loc.locationName ?? "").trim() || "",
  42 + street: loc.street ?? "",
  43 + city: loc.city ?? "",
  44 + stateCode: loc.stateCode ?? "",
  45 + country: loc.country ?? "",
  46 + zipCode: loc.zipCode ?? "",
  47 + phone: loc.phone ?? "",
  48 + email: loc.email ?? "",
  49 + latitude: loc.latitude ?? null,
  50 + longitude: loc.longitude ?? null,
  51 + state: loc.state !== false,
  52 + };
  53 +}
  54 +
  55 +function emptyPadRow(): RowState {
  56 + return {
  57 + id: "",
  58 + locationCodeReadonly: "",
  59 + partner: "",
  60 + groupName: "",
  61 + locationName: "",
  62 + street: "",
  63 + city: "",
  64 + stateCode: "",
  65 + country: "",
  66 + zipCode: "",
  67 + phone: "",
  68 + email: "",
  69 + latitude: null,
  70 + longitude: null,
  71 + state: true,
  72 + };
  73 +}
  74 +
  75 +export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: LocationBulkEditDialogProps) {
  76 + const [rows, setRows] = useState<RowState[]>([]);
  77 + const [saving, setSaving] = useState(false);
  78 +
  79 + const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]);
  80 +
  81 + useEffect(() => {
  82 + if (!open) return;
  83 + const data = seed.map(locToRow);
  84 + const pad = Math.max(0, minRows - data.length);
  85 + setRows([...data, ...Array.from({ length: pad }, () => emptyPadRow())]);
  86 + }, [open, seed, minRows]);
  87 +
  88 + const updateRow = (idx: number, patch: Partial<RowState>) => {
  89 + setRows((prev) => {
  90 + const next = [...prev];
  91 + next[idx] = { ...next[idx], ...patch };
  92 + return next;
  93 + });
  94 + };
  95 +
  96 + const removeRow = (idx: number) => {
  97 + setRows((prev) => {
  98 + if (prev.length <= 1) return prev;
  99 + return prev.filter((_, i) => i !== idx);
  100 + });
  101 + };
  102 +
  103 + const handleSave = async () => {
  104 + const items: LocationBulkUpdateItemVo[] = rows
  105 + .filter((r) => isValidBulkId(r.id))
  106 + .map((r) => ({
  107 + id: r.id.trim(),
  108 + partner: r.partner?.trim() || null,
  109 + groupName: r.groupName?.trim() || null,
  110 + locationName: r.locationName.trim(),
  111 + street: r.street?.trim() || null,
  112 + city: r.city?.trim() || null,
  113 + stateCode: r.stateCode?.trim() || null,
  114 + country: r.country?.trim() || null,
  115 + zipCode: r.zipCode?.trim() || null,
  116 + phone: r.phone?.trim() || null,
  117 + email: r.email?.trim() || null,
  118 + latitude: r.latitude,
  119 + longitude: r.longitude,
  120 + state: r.state !== false,
  121 + }));
  122 +
  123 + if (items.length === 0) {
  124 + toast.error("No valid rows", { description: "Select locations in the list or keep rows with valid IDs." });
  125 + return;
  126 + }
  127 +
  128 + setSaving(true);
  129 + try {
  130 + const res = await updateLocationsBulk({ items });
  131 + toast.success("Bulk update finished", {
  132 + description: `Success: ${res.successCount}, failed: ${res.failCount}`,
  133 + });
  134 + if (res.errors?.length) {
  135 + const preview = res.errors
  136 + .slice(0, 5)
  137 + .map((e) => `Row ${e.rowNumber ?? "?"}: ${e.message ?? ""}`)
  138 + .join("\n");
  139 + toast.message("Errors (first 5)", { description: preview });
  140 + }
  141 + onSaved();
  142 + onOpenChange(false);
  143 + } catch (e) {
  144 + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
  145 + toast.error("Bulk save failed", { description: msg });
  146 + } finally {
  147 + setSaving(false);
  148 + }
  149 + };
  150 +
  151 + return (
  152 + <Dialog open={open} onOpenChange={onOpenChange}>
  153 + <DialogContent className="max-w-[min(96vw,1200px)] w-full max-h-[90vh] flex flex-col gap-0 p-0">
  154 + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">
  155 + <Button type="button" variant="outline" className="shrink-0" onClick={() => onOpenChange(false)}>
  156 + Back
  157 + </Button>
  158 + <DialogHeader className="flex-1 text-center space-y-0 py-0">
  159 + <DialogTitle className="text-base">Location bulk edit</DialogTitle>
  160 + <DialogDescription className="sr-only">Edit multiple locations and save all.</DialogDescription>
  161 + </DialogHeader>
  162 + <Button
  163 + type="button"
  164 + className="bg-green-600 hover:bg-green-700 text-white shrink-0"
  165 + disabled={saving}
  166 + onClick={() => void handleSave()}
  167 + >
  168 + {saving ? "Saving…" : "Save All"}
  169 + </Button>
  170 + </div>
  171 + <div className="overflow-auto flex-1 min-h-0 px-2 py-3">
  172 + <table className="w-full text-xs border-collapse border border-gray-200">
  173 + <thead className="bg-gray-100 sticky top-0 z-10">
  174 + <tr>
  175 + <th className="border p-1 w-8" />
  176 + <th className="border p-1 whitespace-nowrap">Location ID</th>
  177 + <th className="border p-1 whitespace-nowrap">Company</th>
  178 + <th className="border p-1 whitespace-nowrap">Region</th>
  179 + <th className="border p-1 whitespace-nowrap">Location Name *</th>
  180 + <th className="border p-1 whitespace-nowrap">Street</th>
  181 + <th className="border p-1 whitespace-nowrap">City</th>
  182 + <th className="border p-1 whitespace-nowrap">State</th>
  183 + <th className="border p-1 whitespace-nowrap">Country</th>
  184 + <th className="border p-1 whitespace-nowrap">Zip</th>
  185 + <th className="border p-1 whitespace-nowrap">Phone</th>
  186 + <th className="border p-1 whitespace-nowrap">Email</th>
  187 + <th className="border p-1 whitespace-nowrap">Lat</th>
  188 + <th className="border p-1 whitespace-nowrap">Lng</th>
  189 + <th className="border p-1 whitespace-nowrap">Active</th>
  190 + </tr>
  191 + </thead>
  192 + <tbody>
  193 + {rows.map((r, idx) => (
  194 + <tr key={`${r.id || "new"}-${idx}`} className="bg-white">
  195 + <td className="border p-0 align-middle text-center">
  196 + <div className="flex flex-col items-center gap-1 py-1">
  197 + <span className="text-[10px] text-gray-500">{idx + 1}</span>
  198 + <Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-1" onClick={() => removeRow(idx)}>
  199 + ×
  200 + </Button>
  201 + </div>
  202 + </td>
  203 + <td className="border p-1 align-top">
  204 + <Input
  205 + className="h-7 text-xs min-w-[100px]"
  206 + value={r.locationCodeReadonly}
  207 + readOnly
  208 + title="Location ID is not changed in bulk edit"
  209 + />
  210 + </td>
  211 + <td className="border p-1 align-top">
  212 + <Input className="h-7 text-xs min-w-[80px]" value={r.partner ?? ""} onChange={(e) => updateRow(idx, { partner: e.target.value })} />
  213 + </td>
  214 + <td className="border p-1 align-top">
  215 + <Input className="h-7 text-xs min-w-[80px]" value={r.groupName ?? ""} onChange={(e) => updateRow(idx, { groupName: e.target.value })} />
  216 + </td>
  217 + <td className="border p-1 align-top">
  218 + <Input className="h-7 text-xs min-w-[100px]" value={r.locationName} onChange={(e) => updateRow(idx, { locationName: e.target.value })} />
  219 + </td>
  220 + <td className="border p-1 align-top">
  221 + <Input className="h-7 text-xs min-w-[80px]" value={r.street ?? ""} onChange={(e) => updateRow(idx, { street: e.target.value })} />
  222 + </td>
  223 + <td className="border p-1 align-top">
  224 + <Input className="h-7 text-xs min-w-[72px]" value={r.city ?? ""} onChange={(e) => updateRow(idx, { city: e.target.value })} />
  225 + </td>
  226 + <td className="border p-1 align-top">
  227 + <Input className="h-7 text-xs min-w-[48px]" value={r.stateCode ?? ""} onChange={(e) => updateRow(idx, { stateCode: e.target.value })} />
  228 + </td>
  229 + <td className="border p-1 align-top">
  230 + <Input className="h-7 text-xs min-w-[56px]" value={r.country ?? ""} onChange={(e) => updateRow(idx, { country: e.target.value })} />
  231 + </td>
  232 + <td className="border p-1 align-top">
  233 + <Input className="h-7 text-xs min-w-[56px]" value={r.zipCode ?? ""} onChange={(e) => updateRow(idx, { zipCode: e.target.value })} />
  234 + </td>
  235 + <td className="border p-1 align-top">
  236 + <Input className="h-7 text-xs min-w-[88px]" value={r.phone ?? ""} onChange={(e) => updateRow(idx, { phone: e.target.value })} />
  237 + </td>
  238 + <td className="border p-1 align-top">
  239 + <Input className="h-7 text-xs min-w-[120px]" value={r.email ?? ""} onChange={(e) => updateRow(idx, { email: e.target.value })} />
  240 + </td>
  241 + <td className="border p-1 align-top">
  242 + <Input
  243 + className="h-7 text-xs min-w-[64px]"
  244 + value={r.latitude === null || r.latitude === undefined ? "" : String(r.latitude)}
  245 + onChange={(e) => {
  246 + const v = e.target.value.trim();
  247 + updateRow(idx, { latitude: v === "" ? null : Number(v) });
  248 + }}
  249 + />
  250 + </td>
  251 + <td className="border p-1 align-top">
  252 + <Input
  253 + className="h-7 text-xs min-w-[64px]"
  254 + value={r.longitude === null || r.longitude === undefined ? "" : String(r.longitude)}
  255 + onChange={(e) => {
  256 + const v = e.target.value.trim();
  257 + updateRow(idx, { longitude: v === "" ? null : Number(v) });
  258 + }}
  259 + />
  260 + </td>
  261 + <td className="border p-1 align-middle text-center">
  262 + <div className="flex justify-center">
  263 + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />
  264 + </div>
  265 + </td>
  266 + </tr>
  267 + ))}
  268 + </tbody>
  269 + </table>
  270 + </div>
  271 + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">
  272 + <p>You can copy and paste from Excel or Google Sheets.</p>
  273 + <p>Columns marked * are required for rows that have a valid Location row id.</p>
  274 + <p>Use the × on each row to remove a row (keep at least one row).</p>
  275 + </div>
  276 + </DialogContent>
  277 + </Dialog>
  278 + );
  279 +}
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
@@ -68,8 +68,11 @@ import { getLocations } from &quot;../../services/locationService&quot;; @@ -68,8 +68,11 @@ import { getLocations } from &quot;../../services/locationService&quot;;
68 import { 68 import {
69 createTeamMember, 69 createTeamMember,
70 deleteTeamMember, 70 deleteTeamMember,
  71 + downloadTeamMemberImportTemplate,
  72 + exportTeamMembersPdf,
71 getTeamMemberById, 73 getTeamMemberById,
72 getTeamMembers, 74 getTeamMembers,
  75 + importTeamMembersBatch,
73 updateTeamMember, 76 updateTeamMember,
74 } from "../../services/teamMemberService"; 77 } from "../../services/teamMemberService";
75 import type { LocationDto } from "../../types/location"; 78 import type { LocationDto } from "../../types/location";
@@ -85,6 +88,8 @@ import { createGroup, deleteGroup, exportGroupsPdf, getGroups, updateGroup } fro @@ -85,6 +88,8 @@ import { createGroup, deleteGroup, exportGroupsPdf, getGroups, updateGroup } fro
85 import type { GroupListItem } from "../../types/group"; 88 import type { GroupListItem } from "../../types/group";
86 import type { PartnerListItem } from "../../types/partner"; 89 import type { PartnerListItem } from "../../types/partner";
87 import { LocationsView } from "../locations/LocationsView"; 90 import { LocationsView } from "../locations/LocationsView";
  91 +import { BatchImportDialog } from "../bulk/batch-import-dialog";
  92 +import { TeamMemberBulkEditPage } from "./team-member-bulk-edit-page";
88 93
89 function downloadBlob(blob: Blob, filename: string) { 94 function downloadBlob(blob: Blob, filename: string) {
90 const url = URL.createObjectURL(blob); 95 const url = URL.createObjectURL(blob);
@@ -171,6 +176,13 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -171,6 +176,13 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
171 } 176 }
172 }, [initialSubTab, onInitialSubTabConsumed]); 177 }, [initialSubTab, onInitialSubTabConsumed]);
173 178
  179 + useEffect(() => {
  180 + if (activeTab !== "Team Member") {
  181 + setMemberBulkEditPage(false);
  182 + setMemberBulkEditSeed([]);
  183 + }
  184 + }, [activeTab]);
  185 +
174 // Data States 186 // Data States
175 const [roles, setRoles] = useState<RoleDto[]>([]); 187 const [roles, setRoles] = useState<RoleDto[]>([]);
176 const [roleTotal, setRoleTotal] = useState(0); 188 const [roleTotal, setRoleTotal] = useState(0);
@@ -228,6 +240,12 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -228,6 +240,12 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
228 const [editingMember, setEditingMember] = useState<TeamMemberDto | null>(null); 240 const [editingMember, setEditingMember] = useState<TeamMemberDto | null>(null);
229 const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false); 241 const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false);
230 const [deletingMember, setDeletingMember] = useState<TeamMemberDto | null>(null); 242 const [deletingMember, setDeletingMember] = useState<TeamMemberDto | null>(null);
  243 + const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(() => new Set());
  244 + const [memberBulkImportOpen, setMemberBulkImportOpen] = useState(false);
  245 + const [memberBulkEditPage, setMemberBulkEditPage] = useState(false);
  246 + const [memberBulkEditSeed, setMemberBulkEditSeed] = useState<TeamMemberDto[]>([]);
  247 + const [tmplMemberDownloading, setTmplMemberDownloading] = useState(false);
  248 + const [memberPdfExporting, setMemberPdfExporting] = useState(false);
231 249
232 // Dialog States 250 // Dialog States
233 const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); 251 const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false);
@@ -299,6 +317,10 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -299,6 +317,10 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
299 }, [memberKeyword]); 317 }, [memberKeyword]);
300 318
301 useEffect(() => { 319 useEffect(() => {
  320 + setSelectedMemberIds(new Set());
  321 + }, [debouncedMemberKeyword, memberPageIndex]);
  322 +
  323 + useEffect(() => {
302 if (partnerKeywordTimerRef.current) window.clearTimeout(partnerKeywordTimerRef.current); 324 if (partnerKeywordTimerRef.current) window.clearTimeout(partnerKeywordTimerRef.current);
303 partnerKeywordTimerRef.current = window.setTimeout(() => setDebouncedPartnerKeyword(partnerKeyword.trim()), 300); 325 partnerKeywordTimerRef.current = window.setTimeout(() => setDebouncedPartnerKeyword(partnerKeyword.trim()), 300);
304 return () => { 326 return () => {
@@ -545,8 +567,6 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -545,8 +567,6 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
545 ); 567 );
546 568
547 const renderToolbar = () => { 569 const renderToolbar = () => {
548 - const canBulkOps = activeTab === "Team Member";  
549 -  
550 return ( 570 return (
551 <div className="flex flex-col gap-4 pb-4"> 571 <div className="flex flex-col gap-4 pb-4">
552 {/* Search + Actions - one row, style consistent with Labels / Location Manager */} 572 {/* Search + Actions - one row, style consistent with Labels / Location Manager */}
@@ -619,19 +639,63 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -619,19 +639,63 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
619 </> 639 </>
620 )} 640 )}
621 <div className="flex-1" /> 641 <div className="flex-1" />
622 - {canBulkOps && ( 642 + {activeTab === "Team Member" && (
623 <> 643 <>
624 - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> 644 + <Button
  645 + type="button"
  646 + variant="outline"
  647 + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"
  648 + onClick={() => setMemberBulkImportOpen(true)}
  649 + >
625 Bulk Import 650 Bulk Import
626 </Button> 651 </Button>
627 - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"> 652 + <Button
  653 + type="button"
  654 + variant="outline"
  655 + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"
  656 + disabled={memberPdfExporting}
  657 + onClick={async () => {
  658 + setMemberPdfExporting(true);
  659 + try {
  660 + await exportTeamMembersPdf({
  661 + keyword: debouncedMemberKeyword || undefined,
  662 + });
  663 + toast.success("Export started.", { description: "Team member PDF download should begin shortly." });
  664 + } catch (e: unknown) {
  665 + const msg = e instanceof Error ? e.message : "Please try again.";
  666 + toast.error("Export failed.", { description: msg });
  667 + } finally {
  668 + setMemberPdfExporting(false);
  669 + }
  670 + }}
  671 + >
  672 + {memberPdfExporting ? "Exporting…" : "Bulk Export (PDF)"}
  673 + </Button>
  674 + <Button
  675 + type="button"
  676 + variant="outline"
  677 + className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0"
  678 + onClick={() => {
  679 + const seed = members.filter((m) => selectedMemberIds.has(m.id));
  680 + if (seed.length === 0) {
  681 + toast.error("No rows selected", {
  682 + description: "Use the checkboxes on the left, then open Bulk Edit.",
  683 + });
  684 + return;
  685 + }
  686 + setMemberBulkEditSeed(seed);
  687 + setMemberBulkEditPage(true);
  688 + }}
  689 + >
628 Bulk Edit 690 Bulk Edit
629 </Button> 691 </Button>
630 </> 692 </>
631 )} 693 )}
632 - <Button variant="outline" onClick={handleExportPdf} className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">  
633 - Bulk Export (PDF)  
634 - </Button> 694 + {(activeTab === "Partner" || activeTab === "Group") && (
  695 + <Button variant="outline" onClick={handleExportPdf} className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">
  696 + Bulk Export (PDF)
  697 + </Button>
  698 + )}
635 <Button 699 <Button
636 className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0" 700 className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0"
637 onClick={openCreateDialog} 701 onClick={openCreateDialog}
@@ -1032,6 +1096,16 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -1032,6 +1096,16 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
1032 <Table> 1096 <Table>
1033 <TableHeader> 1097 <TableHeader>
1034 <TableRow className="bg-gray-100"> 1098 <TableRow className="bg-gray-100">
  1099 + <TableHead className="font-bold text-black border-r w-10 text-center">
  1100 + <Checkbox
  1101 + checked={members.length > 0 && members.every((m) => selectedMemberIds.has(m.id))}
  1102 + onCheckedChange={(c) => {
  1103 + if (c === true) setSelectedMemberIds(new Set(members.map((m) => m.id)));
  1104 + else setSelectedMemberIds(new Set());
  1105 + }}
  1106 + aria-label="Select all on page"
  1107 + />
  1108 + </TableHead>
1035 <TableHead className="font-bold text-black border-r">Name</TableHead> 1109 <TableHead className="font-bold text-black border-r">Name</TableHead>
1036 <TableHead className="font-bold text-black border-r">Email</TableHead> 1110 <TableHead className="font-bold text-black border-r">Email</TableHead>
1037 <TableHead className="font-bold text-black border-r">Phone</TableHead> 1111 <TableHead className="font-bold text-black border-r">Phone</TableHead>
@@ -1044,19 +1118,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -1044,19 +1118,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
1044 <TableBody> 1118 <TableBody>
1045 {membersLoading ? ( 1119 {membersLoading ? (
1046 <TableRow> 1120 <TableRow>
1047 - <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> 1121 + <TableCell colSpan={8} className="text-center text-sm text-gray-500 py-10">
1048 Loading... 1122 Loading...
1049 </TableCell> 1123 </TableCell>
1050 </TableRow> 1124 </TableRow>
1051 ) : members.length === 0 ? ( 1125 ) : members.length === 0 ? (
1052 <TableRow> 1126 <TableRow>
1053 - <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> 1127 + <TableCell colSpan={8} className="text-center text-sm text-gray-500 py-10">
1054 No results. 1128 No results.
1055 </TableCell> 1129 </TableCell>
1056 </TableRow> 1130 </TableRow>
1057 ) : ( 1131 ) : (
1058 members.map((m) => ( 1132 members.map((m) => (
1059 <TableRow key={m.id}> 1133 <TableRow key={m.id}>
  1134 + <TableCell className="border-r w-10 text-center">
  1135 + <Checkbox
  1136 + checked={selectedMemberIds.has(m.id)}
  1137 + onCheckedChange={(c) => {
  1138 + setSelectedMemberIds((prev) => {
  1139 + const n = new Set(prev);
  1140 + if (c === true) n.add(m.id);
  1141 + else n.delete(m.id);
  1142 + return n;
  1143 + });
  1144 + }}
  1145 + aria-label="Select row"
  1146 + />
  1147 + </TableCell>
1060 <TableCell className="font-medium border-r">{m.fullName ?? m.userName ?? "N/A"}</TableCell> 1148 <TableCell className="font-medium border-r">{m.fullName ?? m.userName ?? "N/A"}</TableCell>
1061 <TableCell className="border-r text-gray-600">{m.email ?? "N/A"}</TableCell> 1149 <TableCell className="border-r text-gray-600">{m.email ?? "N/A"}</TableCell>
1062 <TableCell className="border-r text-gray-600">{m.phone ?? "N/A"}</TableCell> 1150 <TableCell className="border-r text-gray-600">{m.phone ?? "N/A"}</TableCell>
@@ -1186,12 +1274,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -1186,12 +1274,33 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
1186 } 1274 }
1187 }; 1275 };
1188 1276
  1277 + const showTeamMemberBulkPage = activeTab === "Team Member" && memberBulkEditPage;
  1278 +
1189 return ( 1279 return (
1190 <div className="h-full flex flex-col"> 1280 <div className="h-full flex flex-col">
1191 - {activeTab !== "Location Manager" ? renderToolbar() : null} 1281 + {activeTab !== "Location Manager" && !showTeamMemberBulkPage ? renderToolbar() : null}
1192 1282
1193 {activeTab === "Location Manager" ? ( 1283 {activeTab === "Location Manager" ? (
1194 <div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderContent()}</div> 1284 <div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderContent()}</div>
  1285 + ) : showTeamMemberBulkPage ? (
  1286 + <div className="flex-1 min-h-0 flex flex-col overflow-hidden pt-6">
  1287 + <div className="shrink-0 pb-4">{renderAccountTabsRow()}</div>
  1288 + <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
  1289 + <div className="bg-white border border-gray-200 shadow-sm rounded-md flex-1 flex flex-col min-h-0 overflow-hidden">
  1290 + <TeamMemberBulkEditPage
  1291 + seed={memberBulkEditSeed}
  1292 + onBack={() => {
  1293 + setMemberBulkEditPage(false);
  1294 + setMemberBulkEditSeed([]);
  1295 + }}
  1296 + onSaved={() => {
  1297 + setSelectedMemberIds(new Set());
  1298 + setMemberRefreshSeq((x) => x + 1);
  1299 + }}
  1300 + />
  1301 + </div>
  1302 + </div>
  1303 + </div>
1195 ) : ( 1304 ) : (
1196 <div className="flex-1 overflow-auto pt-6"> 1305 <div className="flex-1 overflow-auto pt-6">
1197 <div className="bg-white border border-gray-200 shadow-sm rounded-md">{renderContent()}</div> 1306 <div className="bg-white border border-gray-200 shadow-sm rounded-md">{renderContent()}</div>
@@ -1286,6 +1395,30 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -1286,6 +1395,30 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
1286 setMemberRefreshSeq((x) => x + 1); 1395 setMemberRefreshSeq((x) => x + 1);
1287 }} 1396 }}
1288 /> 1397 />
  1398 + <BatchImportDialog
  1399 + open={memberBulkImportOpen}
  1400 + onOpenChange={setMemberBulkImportOpen}
  1401 + title="Bulk import team members"
  1402 + description="Upload an .xlsx file. Use the official template for column headers."
  1403 + downloadingTemplate={tmplMemberDownloading}
  1404 + onDownloadTemplate={async () => {
  1405 + setTmplMemberDownloading(true);
  1406 + try {
  1407 + await downloadTeamMemberImportTemplate();
  1408 + toast.success("Template downloaded.");
  1409 + } catch (e: unknown) {
  1410 + const msg = e instanceof Error ? e.message : "Download failed.";
  1411 + toast.error("Template download failed", { description: msg });
  1412 + } finally {
  1413 + setTmplMemberDownloading(false);
  1414 + }
  1415 + }}
  1416 + onImportFile={async (file) => {
  1417 + const r = await importTeamMembersBatch(file);
  1418 + setMemberRefreshSeq((x) => x + 1);
  1419 + return { successCount: r.successCount, failCount: r.failCount };
  1420 + }}
  1421 + />
1289 <DeleteMemberDialog 1422 <DeleteMemberDialog
1290 open={isDeleteMemberDialogOpen} 1423 open={isDeleteMemberDialogOpen}
1291 member={deletingMember} 1424 member={deletingMember}
美国版/Food Labeling Management Platform/src/components/people/team-member-bulk-edit-page.tsx 0 → 100644
  1 +import React, { useEffect, useState } from "react";
  2 +import { Button } from "../ui/button";
  3 +import { Input } from "../ui/input";
  4 +import { Switch } from "../ui/switch";
  5 +import {
  6 + Select,
  7 + SelectContent,
  8 + SelectItem,
  9 + SelectTrigger,
  10 + SelectValue,
  11 +} from "../ui/select";
  12 +import { toast } from "sonner";
  13 +import { ApiError } from "../../lib/apiClient";
  14 +import { getRoles } from "../../services/roleService";
  15 +import { updateTeamMembersBulk, type TeamMemberBulkUpdateItemVo } from "../../services/teamMemberService";
  16 +import type { RoleDto } from "../../types/role";
  17 +import type { TeamMemberDto } from "../../types/teamMember";
  18 +
  19 +const ZERO = "00000000-0000-0000-0000-000000000000";
  20 +
  21 +/** 列表/接口可能把 phone 等字段打成数字;统一成字符串再 trim,避免白屏 */
  22 +function trimStr(v: unknown): string {
  23 + if (v == null) return "";
  24 + return String(v).trim();
  25 +}
  26 +
  27 +function isValidBulkId(id: string): boolean {
  28 + const s = (id ?? "").trim();
  29 + if (!s) return false;
  30 + return s.toLowerCase() !== ZERO;
  31 +}
  32 +
  33 +function toPhoneNumber(v: string): number | null {
  34 + const s = v.trim();
  35 + if (!s) return null;
  36 + const num = Number(s.replace(/\D/g, "")) || 0;
  37 + return num;
  38 +}
  39 +
  40 +export type TeamMemberBulkEditPageProps = {
  41 + seed: TeamMemberDto[];
  42 + onBack: () => void;
  43 + onSaved: () => void;
  44 +};
  45 +
  46 +type RowState = {
  47 + id: string;
  48 + fullName: string;
  49 + userName: string;
  50 + password: string;
  51 + email: string;
  52 + phone: string;
  53 + roleId: string;
  54 + locationIdsCsv: string;
  55 + state: boolean;
  56 +};
  57 +
  58 +function memberToRow(m: TeamMemberDto): RowState {
  59 + const lids = Array.isArray(m.locationIds) ? m.locationIds : [];
  60 + return {
  61 + id: trimStr(m.id),
  62 + fullName: trimStr(m.fullName),
  63 + userName: trimStr(m.userName),
  64 + password: "",
  65 + email: trimStr(m.email),
  66 + phone: trimStr(m.phone),
  67 + roleId: trimStr(m.roleId),
  68 + locationIdsCsv: lids.map((x) => trimStr(x)).filter(Boolean).join(","),
  69 + state: m.state !== false,
  70 + };
  71 +}
  72 +
  73 +function parseIdsCsv(s: string): string[] {
  74 + return s
  75 + .split(/[,;|\s]+/)
  76 + .map((x) => x.trim())
  77 + .filter(Boolean);
  78 +}
  79 +
  80 +export function TeamMemberBulkEditPage({ seed, onBack, onSaved }: TeamMemberBulkEditPageProps) {
  81 + const [rows, setRows] = useState<RowState[]>([]);
  82 + const [roles, setRoles] = useState<RoleDto[]>([]);
  83 + const [saving, setSaving] = useState(false);
  84 +
  85 + useEffect(() => {
  86 + let c = false;
  87 + (async () => {
  88 + try {
  89 + const out: RoleDto[] = [];
  90 + let page = 1;
  91 + const size = 100;
  92 + for (;;) {
  93 + const res = await getRoles({ skipCount: page, maxResultCount: size });
  94 + out.push(...(res.items ?? []));
  95 + if (!res.items || res.items.length < size) break;
  96 + page += 1;
  97 + if (page > 50) break;
  98 + }
  99 + if (!c) setRoles(out);
  100 + } catch {
  101 + if (!c) setRoles([]);
  102 + }
  103 + })();
  104 + return () => {
  105 + c = true;
  106 + };
  107 + }, []);
  108 +
  109 + useEffect(() => {
  110 + setRows(seed.map(memberToRow));
  111 + }, [seed]);
  112 +
  113 + const updateRow = (idx: number, patch: Partial<RowState>) => {
  114 + setRows((prev) => {
  115 + const next = [...prev];
  116 + next[idx] = { ...next[idx], ...patch };
  117 + return next;
  118 + });
  119 + };
  120 +
  121 + const handleSave = async () => {
  122 + const items: TeamMemberBulkUpdateItemVo[] = rows
  123 + .filter((r) => isValidBulkId(r.id))
  124 + .map((r) => {
  125 + const item: TeamMemberBulkUpdateItemVo = {
  126 + id: r.id.trim(),
  127 + fullName: r.fullName.trim(),
  128 + userName: r.userName.trim(),
  129 + email: r.email.trim() || null,
  130 + phone: toPhoneNumber(r.phone),
  131 + roleId: r.roleId.trim(),
  132 + locationIds: parseIdsCsv(r.locationIdsCsv),
  133 + state: r.state !== false,
  134 + };
  135 + const pw = r.password.trim();
  136 + if (pw) item.password = pw;
  137 + return item;
  138 + });
  139 +
  140 + if (items.length === 0) {
  141 + toast.error("No valid rows", { description: "Select team members in the list first." });
  142 + return;
  143 + }
  144 +
  145 + setSaving(true);
  146 + try {
  147 + const res = await updateTeamMembersBulk({ items });
  148 + toast.success("Bulk update finished", {
  149 + description: `Success: ${res.successCount}, failed: ${res.failCount}`,
  150 + });
  151 + onSaved();
  152 + onBack();
  153 + } catch (e) {
  154 + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
  155 + toast.error("Bulk save failed", { description: msg });
  156 + } finally {
  157 + setSaving(false);
  158 + }
  159 + };
  160 +
  161 + return (
  162 + <div className="flex flex-col h-full min-h-0 bg-white">
  163 + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">
  164 + <Button type="button" variant="outline" onClick={onBack}>
  165 + Back
  166 + </Button>
  167 + <h1 className="text-base font-semibold text-gray-900 flex-1 text-center truncate px-2">
  168 + Team member bulk edit
  169 + </h1>
  170 + <Button
  171 + type="button"
  172 + className="bg-green-600 hover:bg-green-700 text-white shrink-0"
  173 + disabled={saving}
  174 + onClick={() => void handleSave()}
  175 + >
  176 + {saving ? "Saving…" : "Save All"}
  177 + </Button>
  178 + </div>
  179 + <div className="overflow-auto flex-1 min-h-0 px-2 py-3">
  180 + <table className="w-full text-xs border-collapse border border-gray-200">
  181 + <thead className="bg-gray-100 sticky top-0 z-10">
  182 + <tr>
  183 + <th className="border p-1 w-9 text-center text-gray-600 font-semibold">#</th>
  184 + <th className="border p-1 whitespace-nowrap">Full name *</th>
  185 + <th className="border p-1 whitespace-nowrap">User name *</th>
  186 + <th className="border p-1 whitespace-nowrap">Password</th>
  187 + <th className="border p-1 whitespace-nowrap">Email</th>
  188 + <th className="border p-1 whitespace-nowrap">Phone</th>
  189 + <th className="border p-1 whitespace-nowrap">Role *</th>
  190 + <th className="border p-1 whitespace-nowrap">Location IDs</th>
  191 + <th className="border p-1 whitespace-nowrap">Active</th>
  192 + </tr>
  193 + </thead>
  194 + <tbody>
  195 + {rows.map((r, idx) => (
  196 + <tr key={`${r.id || "e"}-${idx}`}>
  197 + <td className="border p-1 text-center align-middle text-gray-700 tabular-nums text-xs font-medium">
  198 + {idx + 1}
  199 + </td>
  200 + <td className="border p-1 align-top">
  201 + <Input
  202 + className="h-7 text-xs min-w-[100px]"
  203 + value={r.fullName}
  204 + onChange={(e) => updateRow(idx, { fullName: e.target.value })}
  205 + />
  206 + </td>
  207 + <td className="border p-1 align-top">
  208 + <Input
  209 + className="h-7 text-xs min-w-[100px]"
  210 + value={r.userName}
  211 + onChange={(e) => updateRow(idx, { userName: e.target.value })}
  212 + />
  213 + </td>
  214 + <td className="border p-1 align-top">
  215 + <Input
  216 + className="h-7 text-xs min-w-[80px]"
  217 + type="password"
  218 + placeholder="(unchanged)"
  219 + value={r.password}
  220 + onChange={(e) => updateRow(idx, { password: e.target.value })}
  221 + />
  222 + </td>
  223 + <td className="border p-1 align-top">
  224 + <Input
  225 + className="h-7 text-xs min-w-[120px]"
  226 + value={r.email}
  227 + onChange={(e) => updateRow(idx, { email: e.target.value })}
  228 + />
  229 + </td>
  230 + <td className="border p-1 align-top">
  231 + <Input
  232 + className="h-7 text-xs min-w-[88px]"
  233 + value={r.phone}
  234 + onChange={(e) => updateRow(idx, { phone: e.target.value })}
  235 + />
  236 + </td>
  237 + <td className="border p-1 align-top min-w-[140px]">
  238 + <Select value={r.roleId || "__none__"} onValueChange={(v) => updateRow(idx, { roleId: v === "__none__" ? "" : v })}>
  239 + <SelectTrigger className="h-7 text-xs">
  240 + <SelectValue placeholder="Role" />
  241 + </SelectTrigger>
  242 + <SelectContent>
  243 + <SelectItem value="__none__">(select)</SelectItem>
  244 + {roles.map((role) => (
  245 + <SelectItem key={role.id} value={role.id}>
  246 + {role.roleName ?? role.id}
  247 + </SelectItem>
  248 + ))}
  249 + </SelectContent>
  250 + </Select>
  251 + </td>
  252 + <td className="border p-1 align-top">
  253 + <Input
  254 + className="h-7 text-xs min-w-[140px]"
  255 + value={r.locationIdsCsv}
  256 + onChange={(e) => updateRow(idx, { locationIdsCsv: e.target.value })}
  257 + placeholder="guid1,guid2"
  258 + />
  259 + </td>
  260 + <td className="border p-1 text-center align-middle">
  261 + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />
  262 + </td>
  263 + </tr>
  264 + ))}
  265 + </tbody>
  266 + </table>
  267 + </div>
  268 + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">
  269 + <p>Leave password empty to keep the current password.</p>
  270 + <p>Location IDs: comma-separated location primary keys.</p>
  271 + </div>
  272 + </div>
  273 + );
  274 +}
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
@@ -10,6 +10,7 @@ import { @@ -10,6 +10,7 @@ import {
10 Trash2, 10 Trash2,
11 } from "lucide-react"; 11 } from "lucide-react";
12 import { Button } from "../ui/button"; 12 import { Button } from "../ui/button";
  13 +import { Checkbox } from "../ui/checkbox";
13 import { Input } from "../ui/input"; 14 import { Input } from "../ui/input";
14 import { 15 import {
15 Table, 16 Table,
@@ -66,8 +67,11 @@ import { @@ -66,8 +67,11 @@ import {
66 import { 67 import {
67 createProduct, 68 createProduct,
68 deleteProduct, 69 deleteProduct,
  70 + downloadProductImportTemplate,
  71 + exportProductsExcel,
69 getProduct, 72 getProduct,
70 getProducts, 73 getProducts,
  74 + importProductsBatch,
71 updateProduct, 75 updateProduct,
72 } from "../../services/productService"; 76 } from "../../services/productService";
73 import { getProductIdsByLocation, getProductLocations } from "../../services/productLocationService"; 77 import { getProductIdsByLocation, getProductLocations } from "../../services/productLocationService";
@@ -76,6 +80,8 @@ import type { ProductDto, ProductCreateInput, ProductUpdateInput } from &quot;../../t @@ -76,6 +80,8 @@ import type { ProductDto, ProductCreateInput, ProductUpdateInput } from &quot;../../t
76 import type { ProductCategoryDto, ProductCategoryCreateInput } from "../../types/productCategory"; 80 import type { ProductCategoryDto, ProductCategoryCreateInput } from "../../types/productCategory";
77 import { SearchableSelect } from "../ui/searchable-select"; 81 import { SearchableSelect } from "../ui/searchable-select";
78 import { SearchableMultiSelect } from "../ui/searchable-multi-select"; 82 import { SearchableMultiSelect } from "../ui/searchable-multi-select";
  83 +import { BatchImportDialog } from "../bulk/batch-import-dialog";
  84 +import { ProductBulkEditDialog } from "./product-bulk-edit-dialog";
79 import { 85 import {
80 Pagination, 86 Pagination,
81 PaginationContent, 87 PaginationContent,
@@ -162,6 +168,12 @@ export function ProductsView() { @@ -162,6 +168,12 @@ export function ProductsView() {
162 const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null); 168 const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null);
163 const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null); 169 const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null);
164 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null); 170 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null);
  171 + const [selectedProductIds, setSelectedProductIds] = useState<Set<string>>(() => new Set());
  172 + const [bulkImportOpen, setBulkImportOpen] = useState(false);
  173 + const [bulkEditOpen, setBulkEditOpen] = useState(false);
  174 + const [bulkEditSeed, setBulkEditSeed] = useState<ProductDto[]>([]);
  175 + const [tmplDownloading, setTmplDownloading] = useState(false);
  176 + const [excelExporting, setExcelExporting] = useState(false);
165 177
166 useEffect(() => { 178 useEffect(() => {
167 if (keywordTimer.current) window.clearTimeout(keywordTimer.current); 179 if (keywordTimer.current) window.clearTimeout(keywordTimer.current);
@@ -341,6 +353,10 @@ export function ProductsView() { @@ -341,6 +353,10 @@ export function ProductsView() {
341 353
342 const refresh = () => setRefreshSeq((x) => x + 1); 354 const refresh = () => setRefreshSeq((x) => x + 1);
343 355
  356 + useEffect(() => {
  357 + setSelectedProductIds(new Set());
  358 + }, [debouncedKeyword, locationFilter, categoryFilter, stateFilter, pageIndex]);
  359 +
344 const refreshCategories = () => { 360 const refreshCategories = () => {
345 setCatRefreshSeq((x) => x + 1); 361 setCatRefreshSeq((x) => x + 1);
346 reloadCategoryCatalog(); 362 reloadCategoryCatalog();
@@ -461,38 +477,6 @@ export function ProductsView() { @@ -461,38 +477,6 @@ export function ProductsView() {
461 </SelectContent> 477 </SelectContent>
462 </Select> 478 </Select>
463 </div> 479 </div>
464 - <div className="flex flex-nowrap items-center justify-end gap-3 min-w-0 overflow-x-auto pb-0.5 [scrollbar-width:thin]">  
465 - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>  
466 - <Upload className="w-4 h-4" /> Bulk Import  
467 - </Button>  
468 - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>  
469 - <Download className="w-4 h-4" /> Bulk Export  
470 - </Button>  
471 - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>  
472 - <Edit className="w-4 h-4" /> Bulk Edit  
473 - </Button>  
474 - {activeTab === "products" ? (  
475 - <Button  
476 - className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"  
477 - onClick={() => {  
478 - setEditingProduct(null);  
479 - setIsProductDialogOpen(true);  
480 - }}  
481 - >  
482 - New Product <Plus className="w-4 h-4" />  
483 - </Button>  
484 - ) : (  
485 - <Button  
486 - className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"  
487 - onClick={() => {  
488 - setEditingProductCategory(null);  
489 - setIsProductCategoryDialogOpen(true);  
490 - }}  
491 - >  
492 - New Category <Plus className="w-4 h-4" />  
493 - </Button>  
494 - )}  
495 - </div>  
496 </div> 480 </div>
497 481
498 <div className="w-full border-b border-gray-200 mt-4"> 482 <div className="w-full border-b border-gray-200 mt-4">
@@ -533,12 +517,101 @@ export function ProductsView() { @@ -533,12 +517,101 @@ export function ProductsView() {
533 </div> 517 </div>
534 </div> 518 </div>
535 519
536 - <div className="flex-1 overflow-auto pt-6"> 520 + <div className="flex-1 overflow-auto pt-6 flex flex-col min-h-0">
  521 + <div className="flex flex-nowrap items-center justify-end gap-3 min-w-0 overflow-x-auto pb-0.5 mb-3 shrink-0 [scrollbar-width:thin]">
  522 + {activeTab === "products" && (
  523 + <>
  524 + <Button
  525 + type="button"
  526 + variant="outline"
  527 + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"
  528 + onClick={() => setBulkImportOpen(true)}
  529 + >
  530 + <Upload className="w-4 h-4" /> Bulk Import
  531 + </Button>
  532 + <Button
  533 + type="button"
  534 + variant="outline"
  535 + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"
  536 + disabled={excelExporting}
  537 + onClick={async () => {
  538 + setExcelExporting(true);
  539 + try {
  540 + await exportProductsExcel({
  541 + keyword: debouncedKeyword || undefined,
  542 + state: stateFilter === "all" ? undefined : stateFilter === "true",
  543 + });
  544 + toast.success("Export started", { description: "Your browser should download the Excel file." });
  545 + } catch (e: unknown) {
  546 + const msg = e instanceof Error ? e.message : "Please try again.";
  547 + toast.error("Export failed", { description: msg });
  548 + } finally {
  549 + setExcelExporting(false);
  550 + }
  551 + }}
  552 + >
  553 + <Download className="w-4 h-4" /> {excelExporting ? "Exporting…" : "Bulk Export"}
  554 + </Button>
  555 + <Button
  556 + type="button"
  557 + variant="outline"
  558 + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"
  559 + onClick={() => {
  560 + const seed = products
  561 + .filter((p) => selectedProductIds.has(p.id))
  562 + .map((p) => ({
  563 + ...p,
  564 + locationIds: locationMap.get(p.id) ?? p.locationIds ?? [],
  565 + }));
  566 + if (seed.length === 0) {
  567 + toast.error("No rows selected", { description: "Use the checkboxes on the left, then open Bulk Edit." });
  568 + return;
  569 + }
  570 + setBulkEditSeed(seed);
  571 + setBulkEditOpen(true);
  572 + }}
  573 + >
  574 + <Edit className="w-4 h-4" /> Bulk Edit
  575 + </Button>
  576 + </>
  577 + )}
  578 + {activeTab === "products" ? (
  579 + <Button
  580 + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"
  581 + onClick={() => {
  582 + setEditingProduct(null);
  583 + setIsProductDialogOpen(true);
  584 + }}
  585 + >
  586 + New Product <Plus className="w-4 h-4" />
  587 + </Button>
  588 + ) : (
  589 + <Button
  590 + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"
  591 + onClick={() => {
  592 + setEditingProductCategory(null);
  593 + setIsProductCategoryDialogOpen(true);
  594 + }}
  595 + >
  596 + New Category <Plus className="w-4 h-4" />
  597 + </Button>
  598 + )}
  599 + </div>
537 {activeTab === "products" ? ( 600 {activeTab === "products" ? (
538 <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden"> 601 <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden">
539 <Table> 602 <Table>
540 <TableHeader> 603 <TableHeader>
541 <TableRow className="bg-gray-100 hover:bg-gray-100"> 604 <TableRow className="bg-gray-100 hover:bg-gray-100">
  605 + <TableHead className="text-gray-900 font-bold border-r w-10 text-center whitespace-nowrap">
  606 + <Checkbox
  607 + checked={products.length > 0 && products.every((p) => selectedProductIds.has(p.id))}
  608 + onCheckedChange={(c) => {
  609 + if (c === true) setSelectedProductIds(new Set(products.map((p) => p.id)));
  610 + else setSelectedProductIds(new Set());
  611 + }}
  612 + aria-label="Select all on page"
  613 + />
  614 + </TableHead>
542 <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Location</TableHead> 615 <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Location</TableHead>
543 <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product Category</TableHead> 616 <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product Category</TableHead>
544 <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product</TableHead> 617 <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product</TableHead>
@@ -551,13 +624,13 @@ export function ProductsView() { @@ -551,13 +624,13 @@ export function ProductsView() {
551 <TableBody> 624 <TableBody>
552 {loading ? ( 625 {loading ? (
553 <TableRow> 626 <TableRow>
554 - <TableCell colSpan={7} className="text-center text-gray-500 py-10"> 627 + <TableCell colSpan={8} className="text-center text-gray-500 py-10">
555 Loading... 628 Loading...
556 </TableCell> 629 </TableCell>
557 </TableRow> 630 </TableRow>
558 ) : products.length === 0 ? ( 631 ) : products.length === 0 ? (
559 <TableRow> 632 <TableRow>
560 - <TableCell colSpan={7} className="text-center text-gray-500 py-10"> 633 + <TableCell colSpan={8} className="text-center text-gray-500 py-10">
561 No products found. 634 No products found.
562 </TableCell> 635 </TableCell>
563 </TableRow> 636 </TableRow>
@@ -571,6 +644,20 @@ export function ProductsView() { @@ -571,6 +644,20 @@ export function ProductsView() {
571 const active = p.state !== false; 644 const active = p.state !== false;
572 return ( 645 return (
573 <TableRow key={p.id}> 646 <TableRow key={p.id}>
  647 + <TableCell className="border-r w-10 text-center">
  648 + <Checkbox
  649 + checked={selectedProductIds.has(p.id)}
  650 + onCheckedChange={(c) => {
  651 + setSelectedProductIds((prev) => {
  652 + const n = new Set(prev);
  653 + if (c === true) n.add(p.id);
  654 + else n.delete(p.id);
  655 + return n;
  656 + });
  657 + }}
  658 + aria-label="Select row"
  659 + />
  660 + </TableCell>
574 <TableCell className="border-r text-sm max-w-[200px] truncate" title={locText}> 661 <TableCell className="border-r text-sm max-w-[200px] truncate" title={locText}>
575 {locText} 662 {locText}
576 </TableCell> 663 </TableCell>
@@ -657,10 +744,11 @@ export function ProductsView() { @@ -657,10 +744,11 @@ export function ProductsView() {
657 )} 744 )}
658 </TableBody> 745 </TableBody>
659 </Table> 746 </Table>
660 - <div className="flex items-center justify-between px-3 py-2 text-sm text-gray-600 border-t border-gray-100"> 747 + <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm text-gray-600 border-t border-gray-100">
661 <span> 748 <span>
662 - Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}-  
663 - {Math.min(pageIndex * pageSize, total)} of {total} 749 + {total === 0
  750 + ? "Showing 0 of 0"
  751 + : `Showing ${(pageIndex - 1) * pageSize + 1}–${Math.min(pageIndex * pageSize, total)} of ${total}`}
664 </span> 752 </span>
665 <div className="flex items-center gap-2"> 753 <div className="flex items-center gap-2">
666 <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> 754 <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
@@ -675,27 +763,34 @@ export function ProductsView() { @@ -675,27 +763,34 @@ export function ProductsView() {
675 ))} 763 ))}
676 </SelectContent> 764 </SelectContent>
677 </Select> 765 </Select>
678 - <Button  
679 - type="button"  
680 - variant="outline"  
681 - size="sm"  
682 - disabled={pageIndex <= 1}  
683 - onClick={() => setPageIndex((x) => Math.max(1, x - 1))}  
684 - >  
685 - Prev  
686 - </Button>  
687 - <span className="text-xs tabular-nums">  
688 - Page {pageIndex} / {totalPages}  
689 - </span>  
690 - <Button  
691 - type="button"  
692 - variant="outline"  
693 - size="sm"  
694 - disabled={pageIndex >= totalPages}  
695 - onClick={() => setPageIndex((x) => Math.min(totalPages, x + 1))}  
696 - >  
697 - Next  
698 - </Button> 766 + <Pagination className="mx-0 w-auto justify-end">
  767 + <PaginationContent>
  768 + <PaginationItem>
  769 + <PaginationPrevious
  770 + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
  771 + onClick={() => pageIndex > 1 && setPageIndex((x) => Math.max(1, x - 1))}
  772 + aria-disabled={pageIndex <= 1}
  773 + />
  774 + </PaginationItem>
  775 + <PaginationItem>
  776 + <PaginationLink
  777 + className="cursor-default"
  778 + size="default"
  779 + isActive
  780 + onClick={(e) => e.preventDefault()}
  781 + >
  782 + Page {pageIndex} / {totalPages}
  783 + </PaginationLink>
  784 + </PaginationItem>
  785 + <PaginationItem>
  786 + <PaginationNext
  787 + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
  788 + onClick={() => pageIndex < totalPages && setPageIndex((x) => Math.min(totalPages, x + 1))}
  789 + aria-disabled={pageIndex >= totalPages}
  790 + />
  791 + </PaginationItem>
  792 + </PaginationContent>
  793 + </Pagination>
699 </div> 794 </div>
700 </div> 795 </div>
701 </div> 796 </div>
@@ -846,8 +941,9 @@ export function ProductsView() { @@ -846,8 +941,9 @@ export function ProductsView() {
846 941
847 <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3 shrink-0"> 942 <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3 shrink-0">
848 <div className="text-sm text-gray-600"> 943 <div className="text-sm text-gray-600">
849 - Showing {catTotal === 0 ? 0 : (catPageIndex - 1) * catPageSize + 1}-  
850 - {Math.min(catPageIndex * catPageSize, catTotal)} of {catTotal} 944 + {catTotal === 0
  945 + ? "Showing 0 of 0"
  946 + : `Showing ${(catPageIndex - 1) * catPageSize + 1}–${Math.min(catPageIndex * catPageSize, catTotal)} of ${catTotal}`}
851 </div> 947 </div>
852 <div className="flex items-center gap-3"> 948 <div className="flex items-center gap-3">
853 <Select value={String(catPageSize)} onValueChange={(v) => setCatPageSize(Number(v))}> 949 <Select value={String(catPageSize)} onValueChange={(v) => setCatPageSize(Number(v))}>
@@ -927,6 +1023,43 @@ export function ProductsView() { @@ -927,6 +1023,43 @@ export function ProductsView() {
927 onDeleted={refresh} 1023 onDeleted={refresh}
928 /> 1024 />
929 1025
  1026 + <BatchImportDialog
  1027 + open={bulkImportOpen}
  1028 + onOpenChange={setBulkImportOpen}
  1029 + title="Bulk import products"
  1030 + description="Upload an .xlsx file. Use the official template for column headers."
  1031 + downloadingTemplate={tmplDownloading}
  1032 + onDownloadTemplate={async () => {
  1033 + setTmplDownloading(true);
  1034 + try {
  1035 + await downloadProductImportTemplate();
  1036 + toast.success("Template downloaded.");
  1037 + } catch (e: unknown) {
  1038 + const msg = e instanceof Error ? e.message : "Download failed.";
  1039 + toast.error("Template download failed", { description: msg });
  1040 + } finally {
  1041 + setTmplDownloading(false);
  1042 + }
  1043 + }}
  1044 + onImportFile={async (file) => {
  1045 + const r = await importProductsBatch(file);
  1046 + refresh();
  1047 + reloadCategoryCatalog();
  1048 + return { successCount: r.successCount, failCount: r.failCount };
  1049 + }}
  1050 + />
  1051 +
  1052 + <ProductBulkEditDialog
  1053 + open={bulkEditOpen}
  1054 + onOpenChange={setBulkEditOpen}
  1055 + seed={bulkEditSeed}
  1056 + categories={productCategoriesCatalog}
  1057 + onSaved={() => {
  1058 + setSelectedProductIds(new Set());
  1059 + refresh();
  1060 + }}
  1061 + />
  1062 +
930 <ProductCategoryFormDialog 1063 <ProductCategoryFormDialog
931 open={isProductCategoryDialogOpen} 1064 open={isProductCategoryDialogOpen}
932 category={editingProductCategory} 1065 category={editingProductCategory}
美国版/Food Labeling Management Platform/src/components/products/product-bulk-edit-dialog.tsx 0 → 100644
  1 +import React, { useEffect, useMemo, useState } from "react";
  2 +import { Button } from "../ui/button";
  3 +import { Input } from "../ui/input";
  4 +import { Switch } from "../ui/switch";
  5 +import {
  6 + Dialog,
  7 + DialogContent,
  8 + DialogDescription,
  9 + DialogHeader,
  10 + DialogTitle,
  11 +} from "../ui/dialog";
  12 +import {
  13 + Select,
  14 + SelectContent,
  15 + SelectItem,
  16 + SelectTrigger,
  17 + SelectValue,
  18 +} from "../ui/select";
  19 +import { toast } from "sonner";
  20 +import { ApiError } from "../../lib/apiClient";
  21 +import { updateProductsBulk, type ProductBulkUpdateItemVo } from "../../services/productService";
  22 +import type { ProductDto } from "../../types/product";
  23 +import type { ProductCategoryDto } from "../../types/productCategory";
  24 +
  25 +function isValidBulkProductId(id: string): boolean {
  26 + return !!(id ?? "").trim();
  27 +}
  28 +
  29 +export type ProductBulkEditDialogProps = {
  30 + open: boolean;
  31 + onOpenChange: (open: boolean) => void;
  32 + seed: ProductDto[];
  33 + categories: ProductCategoryDto[];
  34 + onSaved: () => void;
  35 +};
  36 +
  37 +type RowState = ProductBulkUpdateItemVo;
  38 +
  39 +function productToRow(p: ProductDto, locationIds: string[]): RowState {
  40 + return {
  41 + id: p.id,
  42 + productCode: p.productCode ?? "",
  43 + productName: (p.productName ?? "").trim(),
  44 + categoryId: (p.categoryId ?? "").trim() || null,
  45 + productImageUrl: p.productImageUrl ?? null,
  46 + state: p.state !== false,
  47 + locationIds: [...locationIds],
  48 + };
  49 +}
  50 +
  51 +function emptyPadRow(): RowState {
  52 + return {
  53 + id: "",
  54 + productCode: "",
  55 + productName: "",
  56 + categoryId: null,
  57 + productImageUrl: null,
  58 + state: true,
  59 + locationIds: [],
  60 + };
  61 +}
  62 +
  63 +function parseIdsCsv(s: string): string[] {
  64 + return s
  65 + .split(/[,;|\s]+/)
  66 + .map((x) => x.trim())
  67 + .filter(Boolean);
  68 +}
  69 +
  70 +export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, onSaved }: ProductBulkEditDialogProps) {
  71 + const [rows, setRows] = useState<RowState[]>([]);
  72 + const [saving, setSaving] = useState(false);
  73 + const [locCsvByIdx, setLocCsvByIdx] = useState<string[]>([]);
  74 +
  75 + const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]);
  76 +
  77 + useEffect(() => {
  78 + if (!open) return;
  79 + const data = seed.map((p) => productToRow(p, p.locationIds ?? []));
  80 + const pad = Math.max(0, minRows - data.length);
  81 + const padded = [...data, ...Array.from({ length: pad }, () => emptyPadRow())];
  82 + setRows(padded);
  83 + setLocCsvByIdx(
  84 + padded.map((r) => (r.locationIds && r.locationIds.length ? r.locationIds.join(",") : "")),
  85 + );
  86 + }, [open, seed, minRows]);
  87 +
  88 + const updateRow = (idx: number, patch: Partial<RowState>) => {
  89 + setRows((prev) => {
  90 + const next = [...prev];
  91 + next[idx] = { ...next[idx], ...patch };
  92 + return next;
  93 + });
  94 + };
  95 +
  96 + const removeRow = (idx: number) => {
  97 + setRows((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx)));
  98 + setLocCsvByIdx((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx)));
  99 + };
  100 +
  101 + const syncLocIds = (idx: number, csv: string) => {
  102 + const nextCsv = [...locCsvByIdx];
  103 + nextCsv[idx] = csv;
  104 + setLocCsvByIdx(nextCsv);
  105 + updateRow(idx, { locationIds: parseIdsCsv(csv) });
  106 + };
  107 +
  108 + const handleSave = async () => {
  109 + const items: ProductBulkUpdateItemVo[] = rows
  110 + .filter((r) => isValidBulkProductId(r.id))
  111 + .map((r, i) => ({
  112 + id: r.id.trim(),
  113 + productCode: String(r.productCode ?? "").trim() || null,
  114 + productName: r.productName.trim(),
  115 + categoryId: r.categoryId?.trim() ? r.categoryId.trim() : null,
  116 + productImageUrl: r.productImageUrl?.trim() ? r.productImageUrl.trim() : null,
  117 + state: r.state !== false,
  118 + locationIds: parseIdsCsv(locCsvByIdx[i] ?? ""),
  119 + }));
  120 +
  121 + if (items.length === 0) {
  122 + toast.error("No valid rows", { description: "Select products in the list first." });
  123 + return;
  124 + }
  125 +
  126 + setSaving(true);
  127 + try {
  128 + const res = await updateProductsBulk({ items });
  129 + toast.success("Bulk update finished", {
  130 + description: `Success: ${res.successCount}, failed: ${res.failCount}`,
  131 + });
  132 + onSaved();
  133 + onOpenChange(false);
  134 + } catch (e) {
  135 + const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
  136 + toast.error("Bulk save failed", { description: msg });
  137 + } finally {
  138 + setSaving(false);
  139 + }
  140 + };
  141 +
  142 + return (
  143 + <Dialog open={open} onOpenChange={onOpenChange}>
  144 + <DialogContent className="max-w-[min(96vw,1100px)] w-full max-h-[90vh] flex flex-col gap-0 p-0">
  145 + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">
  146 + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
  147 + Back
  148 + </Button>
  149 + <DialogHeader className="flex-1 text-center space-y-0 py-0">
  150 + <DialogTitle className="text-base">Product bulk edit</DialogTitle>
  151 + <DialogDescription className="sr-only">Edit products in a grid and save all.</DialogDescription>
  152 + </DialogHeader>
  153 + <Button type="button" className="bg-green-600 hover:bg-green-700 text-white shrink-0" disabled={saving} onClick={() => void handleSave()}>
  154 + {saving ? "Saving…" : "Save All"}
  155 + </Button>
  156 + </div>
  157 + <div className="overflow-auto flex-1 min-h-0 px-2 py-3">
  158 + <table className="w-full text-xs border-collapse border border-gray-200">
  159 + <thead className="bg-gray-100 sticky top-0 z-10">
  160 + <tr>
  161 + <th className="border p-1 w-8" />
  162 + <th className="border p-1 whitespace-nowrap">Product Code</th>
  163 + <th className="border p-1 whitespace-nowrap">Product *</th>
  164 + <th className="border p-1 whitespace-nowrap">Category</th>
  165 + <th className="border p-1 whitespace-nowrap">Image URL</th>
  166 + <th className="border p-1 whitespace-nowrap">Location IDs</th>
  167 + <th className="border p-1 whitespace-nowrap">Active</th>
  168 + </tr>
  169 + </thead>
  170 + <tbody>
  171 + {rows.map((r, idx) => (
  172 + <tr key={`${r.id || "e"}-${idx}`}>
  173 + <td className="border p-0 text-center align-top">
  174 + <div className="flex flex-col items-center gap-1 py-1">
  175 + <span className="text-[10px] text-gray-500">{idx + 1}</span>
  176 + <Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-1" onClick={() => removeRow(idx)}>
  177 + ×
  178 + </Button>
  179 + </div>
  180 + </td>
  181 + <td className="border p-1 align-top">
  182 + <Input className="h-7 text-xs" value={r.productCode ?? ""} onChange={(e) => updateRow(idx, { productCode: e.target.value })} />
  183 + </td>
  184 + <td className="border p-1 align-top">
  185 + <Input className="h-7 text-xs min-w-[120px]" value={r.productName} onChange={(e) => updateRow(idx, { productName: e.target.value })} />
  186 + </td>
  187 + <td className="border p-1 align-top min-w-[140px]">
  188 + <Select
  189 + value={r.categoryId ?? "__none__"}
  190 + onValueChange={(v) => updateRow(idx, { categoryId: v === "__none__" ? null : v })}
  191 + >
  192 + <SelectTrigger className="h-7 text-xs">
  193 + <SelectValue placeholder="Category" />
  194 + </SelectTrigger>
  195 + <SelectContent>
  196 + <SelectItem value="__none__">(none)</SelectItem>
  197 + {categories.map((c) => (
  198 + <SelectItem key={c.id} value={c.id}>
  199 + {c.categoryName ?? c.id}
  200 + </SelectItem>
  201 + ))}
  202 + </SelectContent>
  203 + </Select>
  204 + </td>
  205 + <td className="border p-1 align-top">
  206 + <Input
  207 + className="h-7 text-xs min-w-[140px]"
  208 + value={r.productImageUrl ?? ""}
  209 + onChange={(e) => updateRow(idx, { productImageUrl: e.target.value || null })}
  210 + />
  211 + </td>
  212 + <td className="border p-1 align-top">
  213 + <Input
  214 + className="h-7 text-xs min-w-[160px]"
  215 + value={locCsvByIdx[idx] ?? ""}
  216 + onChange={(e) => syncLocIds(idx, e.target.value)}
  217 + placeholder="id1,id2"
  218 + />
  219 + </td>
  220 + <td className="border p-1 text-center align-middle">
  221 + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />
  222 + </td>
  223 + </tr>
  224 + ))}
  225 + </tbody>
  226 + </table>
  227 + </div>
  228 + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">
  229 + <p>Columns with * are required for saved rows.</p>
  230 + <p>Location IDs: comma-separated location primary keys (GUID).</p>
  231 + </div>
  232 + </DialogContent>
  233 + </Dialog>
  234 + );
  235 +}
美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx
1 -import React, { useCallback, useEffect, useRef, useState } from "react"; 1 +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
  2 +import type { DateRange } from "react-day-picker";
2 import { Search, Download, Printer, Calendar as CalendarIcon, BarChart3, LineChart, ArrowUpRight, RefreshCw, FileText } from "lucide-react"; 3 import { Search, Download, Printer, Calendar as CalendarIcon, BarChart3, LineChart, ArrowUpRight, RefreshCw, FileText } from "lucide-react";
3 import { Button } from "../ui/button"; 4 import { Button } from "../ui/button";
4 import { Input } from "../ui/input"; 5 import { Input } from "../ui/input";
@@ -16,11 +17,9 @@ import { getLocations } from &quot;../../services/locationService&quot;; @@ -16,11 +17,9 @@ import { getLocations } from &quot;../../services/locationService&quot;;
16 import { getPartners } from "../../services/partnerService"; 17 import { getPartners } from "../../services/partnerService";
17 import { getGroups } from "../../services/groupService"; 18 import { getGroups } from "../../services/groupService";
18 import { 19 import {
19 - exportLabelReportPdf,  
20 - exportPrintLogPdf, 20 + exportPrintLogExcel,
21 getLabelReport, 21 getLabelReport,
22 getReportsPrintLogList, 22 getReportsPrintLogList,
23 - reprintPrintLog,  
24 } from "../../services/reportsService"; 23 } from "../../services/reportsService";
25 import type { LocationDto } from "../../types/location"; 24 import type { LocationDto } from "../../types/location";
26 import type { PartnerListItem } from "../../types/partner"; 25 import type { PartnerListItem } from "../../types/partner";
@@ -98,6 +97,62 @@ function formatTrendLabel(iso: string): string { @@ -98,6 +97,62 @@ function formatTrendLabel(iso: string): string {
98 return d.toLocaleDateString("en-US", { month: "numeric", day: "numeric" }); 97 return d.toLocaleDateString("en-US", { month: "numeric", day: "numeric" });
99 } 98 }
100 99
  100 +/** Reports 筛选:单弹层 + range 日历,避免双日历布局问题 */
  101 +function PeriodRangePicker({
  102 + startDate,
  103 + endDate,
  104 + onRangeChange,
  105 +}: {
  106 + startDate: string;
  107 + endDate: string;
  108 + onRangeChange: (start: string, end: string) => void;
  109 +}) {
  110 + const [open, setOpen] = useState(false);
  111 + const selectedRange: DateRange | undefined = useMemo(() => {
  112 + const from = parseIsoDate(startDate);
  113 + const to = parseIsoDate(endDate);
  114 + if (from && to) return { from, to };
  115 + if (from) return { from, to: undefined };
  116 + return undefined;
  117 + }, [startDate, endDate]);
  118 +
  119 + const label = `${startDate || "YYYY-MM-DD"} — ${endDate || "YYYY-MM-DD"}`;
  120 +
  121 + return (
  122 + <div className="flex items-center gap-2 shrink-0" lang="en-US">
  123 + <span className="text-sm font-medium text-gray-700">Period Search:</span>
  124 + <Popover open={open} onOpenChange={setOpen}>
  125 + <PopoverTrigger asChild>
  126 + <Button
  127 + type="button"
  128 + variant="outline"
  129 + className="h-10 min-w-[17rem] justify-start gap-2 border border-gray-300 bg-white px-3 text-sm font-mono tabular-nums text-gray-900 hover:bg-gray-50"
  130 + >
  131 + <CalendarIcon className="h-4 w-4 shrink-0 text-gray-500" aria-hidden />
  132 + {label}
  133 + </Button>
  134 + </PopoverTrigger>
  135 + <PopoverContent className="w-auto p-0" align="start">
  136 + <Calendar
  137 + mode="range"
  138 + numberOfMonths={1}
  139 + defaultMonth={parseIsoDate(startDate) ?? parseIsoDate(endDate) ?? new Date()}
  140 + selected={selectedRange}
  141 + onSelect={(range) => {
  142 + if (!range?.from) return;
  143 + const s = formatIsoDate(range.from);
  144 + const e = range.to ? formatIsoDate(range.to) : s;
  145 + onRangeChange(s, e);
  146 + if (range.from && range.to) setOpen(false);
  147 + }}
  148 + initialFocus
  149 + />
  150 + </PopoverContent>
  151 + </Popover>
  152 + </div>
  153 + );
  154 +}
  155 +
101 function templateCell(template: string) { 156 function templateCell(template: string) {
102 const t = template ?? ""; 157 const t = template ?? "";
103 if (!t.trim()) return <span className="text-gray-500">None</span>; 158 if (!t.trim()) return <span className="text-gray-500">None</span>;
@@ -169,7 +224,6 @@ export function ReportsView({ @@ -169,7 +224,6 @@ export function ReportsView({
169 const [labelData, setLabelData] = useState<LabelReportData | null>(null); 224 const [labelData, setLabelData] = useState<LabelReportData | null>(null);
170 const [labelLoading, setLabelLoading] = useState(false); 225 const [labelLoading, setLabelLoading] = useState(false);
171 const [exporting, setExporting] = useState(false); 226 const [exporting, setExporting] = useState(false);
172 - const [reprintBusyId, setReprintBusyId] = useState<string | null>(null);  
173 const [filterMetaLoading, setFilterMetaLoading] = useState(true); 227 const [filterMetaLoading, setFilterMetaLoading] = useState(true);
174 228
175 const printAbortRef = useRef<AbortController | null>(null); 229 const printAbortRef = useRef<AbortController | null>(null);
@@ -350,41 +404,17 @@ export function ReportsView({ @@ -350,41 +404,17 @@ export function ReportsView({
350 setLocationId(ALL); 404 setLocationId(ALL);
351 }; 405 };
352 406
353 - const handleReprint = async (row: ReportsPrintLogListItem) => {  
354 - const loc = (row.locationId ?? "").trim();  
355 - const task = (row.taskId ?? "").trim();  
356 - if (!loc || !task) {  
357 - toast.error("Cannot reprint", { description: "Missing location or task id." });  
358 - return;  
359 - }  
360 - const key = task;  
361 - setReprintBusyId(key);  
362 - try {  
363 - await reprintPrintLog({ locationId: loc, taskId: task, printQuantity: 1 });  
364 - toast.success("Reprint request sent", { description: `Task ${row.labelCode || task}` });  
365 - } catch (e) {  
366 - const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again.";  
367 - toast.error("Reprint failed", { description: msg });  
368 - } finally {  
369 - setReprintBusyId(null);  
370 - }  
371 - };  
372 -  
373 const handleExport = async () => { 407 const handleExport = async () => {
374 const f = buildReportFilters(); 408 const f = buildReportFilters();
375 setExporting(true); 409 setExporting(true);
376 try { 410 try {
377 - if (activeTab === "print-log") {  
378 - await exportPrintLogPdf({  
379 - ...f,  
380 - skipCount: 1,  
381 - maxResultCount: 10,  
382 - sorting: "PrintedAt desc",  
383 - });  
384 - } else {  
385 - await exportLabelReportPdf(f);  
386 - }  
387 - toast.success("Export ready", { description: "The PDF download should start shortly." }); 411 + await exportPrintLogExcel({
  412 + ...f,
  413 + skipCount: 1,
  414 + maxResultCount: 10,
  415 + sorting: "PrintedAt desc",
  416 + });
  417 + toast.success("Export ready", { description: "The Excel download should start shortly." });
388 } catch (e) { 418 } catch (e) {
389 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again."; 419 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again.";
390 toast.error("Export failed", { description: msg }); 420 toast.error("Export failed", { description: msg });
@@ -445,57 +475,14 @@ export function ReportsView({ @@ -445,57 +475,14 @@ export function ReportsView({
445 ))} 475 ))}
446 </SelectContent> 476 </SelectContent>
447 </Select> 477 </Select>
448 - <div className="flex items-center gap-2 shrink-0" lang="en-US">  
449 - <span className="text-sm font-medium text-gray-700">Period Search:</span>  
450 - <div  
451 - className="flex items-center bg-white border border-gray-300 rounded-md h-10 px-2"  
452 - style={{ minHeight: 40 }}  
453 - lang="en-US"  
454 - >  
455 - <CalendarIcon className="w-4 h-4 text-gray-500 mr-2 shrink-0" aria-hidden />  
456 - <Popover>  
457 - <PopoverTrigger asChild>  
458 - <Button  
459 - type="button"  
460 - variant="ghost"  
461 - className="h-8 w-[10.5rem] justify-start px-0 text-sm font-mono tabular-nums hover:bg-transparent"  
462 - >  
463 - {startDate || "YYYY-MM-DD"}  
464 - </Button>  
465 - </PopoverTrigger>  
466 - <PopoverContent className="w-auto p-0" align="start">  
467 - <Calendar  
468 - mode="single"  
469 - selected={parseIsoDate(startDate)}  
470 - onSelect={(d) => d && setStartDate(formatIsoDate(d))}  
471 - initialFocus  
472 - />  
473 - </PopoverContent>  
474 - </Popover>  
475 - <span className="mx-2 text-gray-400" aria-hidden>  
476 - -  
477 - </span>  
478 - <Popover>  
479 - <PopoverTrigger asChild>  
480 - <Button  
481 - type="button"  
482 - variant="ghost"  
483 - className="h-8 w-[10.5rem] justify-start px-0 text-sm font-mono tabular-nums hover:bg-transparent"  
484 - >  
485 - {endDate || "YYYY-MM-DD"}  
486 - </Button>  
487 - </PopoverTrigger>  
488 - <PopoverContent className="w-auto p-0" align="start">  
489 - <Calendar  
490 - mode="single"  
491 - selected={parseIsoDate(endDate)}  
492 - onSelect={(d) => d && setEndDate(formatIsoDate(d))}  
493 - initialFocus  
494 - />  
495 - </PopoverContent>  
496 - </Popover>  
497 - </div>  
498 - </div> 478 + <PeriodRangePicker
  479 + startDate={startDate}
  480 + endDate={endDate}
  481 + onRangeChange={(start, end) => {
  482 + setStartDate(start);
  483 + setEndDate(end);
  484 + }}
  485 + />
499 <div 486 <div
500 className="flex items-center w-64 rounded-md border border-gray-300 bg-white overflow-hidden shrink-0" 487 className="flex items-center w-64 rounded-md border border-gray-300 bg-white overflow-hidden shrink-0"
501 style={{ height: 40 }} 488 style={{ height: 40 }}
@@ -509,15 +496,17 @@ export function ReportsView({ @@ -509,15 +496,17 @@ export function ReportsView({
509 /> 496 />
510 </div> 497 </div>
511 <div className="flex-1 min-w-2" /> 498 <div className="flex-1 min-w-2" />
512 - <Button  
513 - type="button"  
514 - variant="outline"  
515 - className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"  
516 - disabled={exporting}  
517 - onClick={() => void handleExport()}  
518 - >  
519 - <Download className="w-4 h-4" /> {exporting ? "Exporting…" : "Export Report"}  
520 - </Button> 499 + {activeTab === "print-log" && (
  500 + <Button
  501 + type="button"
  502 + variant="outline"
  503 + className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"
  504 + disabled={exporting}
  505 + onClick={() => void handleExport()}
  506 + >
  507 + <Download className="w-4 h-4" /> {exporting ? "Exporting…" : "Export Report"}
  508 + </Button>
  509 + )}
521 </div> 510 </div>
522 511
523 <div className="w-full border-b border-gray-200 mt-4"> 512 <div className="w-full border-b border-gray-200 mt-4">
@@ -599,17 +588,13 @@ export function ReportsView({ @@ -599,17 +588,13 @@ export function ReportsView({
599 <TableCell className="border-r text-gray-600 text-sm font-numeric">{toDisplay(log.locationText)}</TableCell> 588 <TableCell className="border-r text-gray-600 text-sm font-numeric">{toDisplay(log.locationText)}</TableCell>
600 <TableCell className="border-r text-sm font-mono text-gray-800">{toDisplay(log.expiryDateText)}</TableCell> 589 <TableCell className="border-r text-sm font-mono text-gray-800">{toDisplay(log.expiryDateText)}</TableCell>
601 <TableCell className="text-center"> 590 <TableCell className="text-center">
602 - <Button  
603 - type="button"  
604 - size="sm"  
605 - variant="outline"  
606 - className="h-8 gap-1 hover:bg-gray-100 border-gray-300"  
607 - disabled={reprintBusyId === (log.taskId || "")}  
608 - onClick={() => void handleReprint(log)} 591 + <div
  592 + className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-gray-300 bg-white px-4 text-sm font-medium text-gray-900 shadow-sm select-none pointer-events-none"
  593 + aria-label="Reprint"
609 > 594 >
610 - <Printer className="w-3 h-3" />  
611 - {reprintBusyId === (log.taskId || "") ? "…" : "Reprint"}  
612 - </Button> 595 + <Printer className="w-3 h-3 shrink-0 text-gray-700" aria-hidden />
  596 + <span>Reprint</span>
  597 + </div>
613 </TableCell> 598 </TableCell>
614 </TableRow> 599 </TableRow>
615 ))} 600 ))}
@@ -633,7 +618,12 @@ export function ReportsView({ @@ -633,7 +618,12 @@ export function ReportsView({
633 /> 618 />
634 </PaginationItem> 619 </PaginationItem>
635 <PaginationItem> 620 <PaginationItem>
636 - <PaginationLink className="cursor-default" isActive onClick={(e) => e.preventDefault()}> 621 + <PaginationLink
  622 + className="cursor-default"
  623 + size="default"
  624 + isActive
  625 + onClick={(e) => e.preventDefault()}
  626 + >
637 Page {pageIndex} / {totalPages} 627 Page {pageIndex} / {totalPages}
638 </PaginationLink> 628 </PaginationLink>
639 </PaginationItem> 629 </PaginationItem>
美国版/Food Labeling Management Platform/src/components/ui/calendar.tsx
@@ -18,7 +18,7 @@ function Calendar({ @@ -18,7 +18,7 @@ function Calendar({
18 showOutsideDays={showOutsideDays} 18 showOutsideDays={showOutsideDays}
19 className={cn("p-3", className)} 19 className={cn("p-3", className)}
20 classNames={{ 20 classNames={{
21 - months: "flex flex-col sm:flex-row gap-2", 21 + months: "flex flex-col gap-4 sm:flex-row sm:gap-2",
22 month: "flex flex-col gap-4", 22 month: "flex flex-col gap-4",
23 caption: "flex justify-center pt-1 relative items-center w-full", 23 caption: "flex justify-center pt-1 relative items-center w-full",
24 caption_label: "text-sm font-medium", 24 caption_label: "text-sm font-medium",
@@ -29,20 +29,21 @@ function Calendar({ @@ -29,20 +29,21 @@ function Calendar({
29 ), 29 ),
30 nav_button_previous: "absolute left-1", 30 nav_button_previous: "absolute left-1",
31 nav_button_next: "absolute right-1", 31 nav_button_next: "absolute right-1",
32 - table: "w-full border-collapse space-x-1",  
33 - head_row: "flex", 32 + /** 使用表格行布局;勿对 tr 使用 flex,否则日期格会挤成一串 */
  33 + table: "w-full border-collapse",
  34 + head_row: "",
34 head_cell: 35 head_cell:
35 - "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",  
36 - row: "flex w-full mt-2", 36 + "text-muted-foreground w-9 text-center text-[0.8rem] font-normal p-0 align-middle",
  37 + row: "mt-2",
37 cell: cn( 38 cell: cn(
38 - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md", 39 + "relative p-0 text-center text-sm align-middle focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
39 props.mode === "range" 40 props.mode === "range"
40 ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" 41 ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
41 : "[&:has([aria-selected])]:rounded-md", 42 : "[&:has([aria-selected])]:rounded-md",
42 ), 43 ),
43 day: cn( 44 day: cn(
44 buttonVariants({ variant: "ghost" }), 45 buttonVariants({ variant: "ghost" }),
45 - "size-8 p-0 font-normal aria-selected:opacity-100", 46 + "h-9 w-9 p-0 font-normal aria-selected:opacity-100",
46 ), 47 ),
47 day_range_start: 48 day_range_start:
48 "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", 49 "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
美国版/Food Labeling Management Platform/src/components/ui/image-url-upload.tsx
@@ -72,8 +72,11 @@ export function ImageUrlUpload({ @@ -72,8 +72,11 @@ export function ImageUrlUpload({
72 if (!busy) inputRef.current?.click(); 72 if (!busy) inputRef.current?.click();
73 }; 73 };
74 74
75 - const boxBase =  
76 - "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"; 75 + /** 未传 boxClassName 时用默认「自适应宽度 + 正方形」;传入 boxClassName 时不再附带 w-full/aspect,避免与固定宽高冲突 */
  76 + const hasCustomBox = Boolean(boxClassName?.trim());
  77 + const boxShell = hasCustomBox
  78 + ? "rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
  79 + : "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2";
77 80
78 return ( 81 return (
79 <div className={cn("space-y-2", className)}> 82 <div className={cn("space-y-2", className)}>
@@ -94,14 +97,14 @@ export function ImageUrlUpload({ @@ -94,14 +97,14 @@ export function ImageUrlUpload({
94 onClick={openPicker} 97 onClick={openPicker}
95 aria-label={emptyLabel || "Upload image"} 98 aria-label={emptyLabel || "Upload image"}
96 className={cn( 99 className={cn(
97 - boxBase, 100 + boxShell,
  101 + hasCustomBox ? boxClassName : null,
98 "flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400", 102 "flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400",
99 emptyLabel && !uploading 103 emptyLabel && !uploading
100 ? "flex-col items-center justify-center gap-2" 104 ? "flex-col items-center justify-center gap-2"
101 : "items-center justify-center", 105 : "items-center justify-center",
102 "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500", 106 "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500",
103 "disabled:pointer-events-none disabled:opacity-50", 107 "disabled:pointer-events-none disabled:opacity-50",
104 - boxClassName,  
105 )} 108 )}
106 > 109 >
107 {uploading ? ( 110 {uploading ? (
@@ -120,9 +123,9 @@ export function ImageUrlUpload({ @@ -120,9 +123,9 @@ export function ImageUrlUpload({
120 ) : ( 123 ) : (
121 <div 124 <div
122 className={cn( 125 className={cn(
123 - "group relative overflow-hidden rounded-md border-2 border-dashed border-gray-300 bg-gray-50/80",  
124 - boxBase,  
125 - boxClassName, 126 + "group relative overflow-hidden border-2 border-dashed border-gray-300 bg-gray-50/80",
  127 + boxShell,
  128 + hasCustomBox ? boxClassName : null,
126 )} 129 )}
127 > 130 >
128 <button 131 <button
美国版/Food Labeling Management Platform/src/lib/batchFileHttp.ts 0 → 100644
  1 +import { ApiError } from "./apiClient";
  2 +
  3 +const API_PREFIX = "/api/app";
  4 +
  5 +function joinUrl(baseUrl: string, path: string): string {
  6 + if (!baseUrl) return path;
  7 + const b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
  8 + const p = path.startsWith("/") ? path : `/${path}`;
  9 + return `${b}${p}`;
  10 +}
  11 +
  12 +function toQueryString(params: Record<string, unknown>): string {
  13 + const qs = new URLSearchParams();
  14 + for (const [k, v] of Object.entries(params)) {
  15 + if (v === undefined || v === null || v === "") continue;
  16 + if (typeof v === "boolean") {
  17 + qs.set(k, v ? "true" : "false");
  18 + continue;
  19 + }
  20 + qs.set(k, String(v));
  21 + }
  22 + const s = qs.toString();
  23 + return s ? `?${s}` : "";
  24 +}
  25 +
  26 +function getTokenForFetch(): string | null {
  27 + try {
  28 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  29 + } catch {
  30 + return null;
  31 + }
  32 +}
  33 +
  34 +function parseFileNameFromContentDisposition(h: string | null): string | null {
  35 + if (!h) return null;
  36 + const m = /filename\*?=(?:UTF-8''|)([^;]+)/i.exec(h);
  37 + if (m?.[1]) return decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, ""));
  38 + return null;
  39 +}
  40 +
  41 +function getAbpErrorMessage(payload: unknown): string | null {
  42 + if (!payload || typeof payload !== "object") return null;
  43 + const p = payload as { error?: { message?: string } };
  44 + return p.error?.message?.trim() || null;
  45 +}
  46 +
  47 +function unwrapEnvelope<T>(payload: unknown): T {
  48 + if (!payload || typeof payload !== "object") return payload as T;
  49 + const w = payload as Record<string, unknown>;
  50 + if ("data" in w && w.data !== undefined) {
  51 + if (w.succeeded === false) {
  52 + const msg =
  53 + (typeof (w.error as { message?: string } | undefined)?.message === "string"
  54 + ? (w.error as { message: string }).message.trim()
  55 + : "") ||
  56 + getAbpErrorMessage(payload) ||
  57 + "Request failed.";
  58 + throw new ApiError(msg, typeof w.statusCode === "number" ? w.statusCode : 400, payload);
  59 + }
  60 + return w.data as T;
  61 + }
  62 + return payload as T;
  63 +}
  64 +
  65 +/**
  66 + * GET 下载二进制(Excel / PDF / 模板),触发浏览器保存。
  67 + */
  68 +export async function authorizedGetBlobDownload(opts: {
  69 + path: string;
  70 + query?: Record<string, unknown>;
  71 + defaultFileName: string;
  72 + signal?: AbortSignal;
  73 +}): Promise<void> {
  74 + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
  75 + const url = joinUrl(baseUrl, `${API_PREFIX}${opts.path}${toQueryString(opts.query ?? {})}`);
  76 + const token = getTokenForFetch();
  77 + const res = await fetch(url, {
  78 + method: "GET",
  79 + headers: token ? { Authorization: `Bearer ${token}` } : {},
  80 + signal: opts.signal,
  81 + });
  82 + const ct = res.headers.get("content-type") ?? "";
  83 + if (!res.ok) {
  84 + if (ct.includes("application/json")) {
  85 + const payload = await res.json().catch(() => null);
  86 + const msg = getAbpErrorMessage(payload) || "Download failed.";
  87 + throw new ApiError(msg, res.status, payload);
  88 + }
  89 + const t = await res.text().catch(() => "");
  90 + throw new ApiError(t || "Download failed.", res.status, t);
  91 + }
  92 + if (ct.includes("application/json")) {
  93 + const payload = await res.json().catch(() => null);
  94 + const msg = getAbpErrorMessage(payload) || "Download failed.";
  95 + throw new ApiError(msg, res.status, payload);
  96 + }
  97 + const blob = await res.blob();
  98 + const name = parseFileNameFromContentDisposition(res.headers.get("content-disposition")) || opts.defaultFileName;
  99 + const href = URL.createObjectURL(blob);
  100 + const a = document.createElement("a");
  101 + a.href = href;
  102 + a.download = name;
  103 + a.click();
  104 + URL.revokeObjectURL(href);
  105 +}
  106 +
  107 +/**
  108 + * multipart 上传文件,解析 JSON 响应(兼容 ABP 包裹 `{ data, succeeded }`)。
  109 + */
  110 +export async function authorizedPostMultipartJson<T>(opts: {
  111 + path: string;
  112 + fieldName: string;
  113 + file: File;
  114 + signal?: AbortSignal;
  115 +}): Promise<T> {
  116 + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
  117 + const url = joinUrl(baseUrl, `${API_PREFIX}${opts.path}`);
  118 + const token = getTokenForFetch();
  119 + const fd = new FormData();
  120 + fd.append(opts.fieldName, opts.file);
  121 + const headers: Record<string, string> = {};
  122 + if (token) headers.Authorization = `Bearer ${token}`;
  123 + const res = await fetch(url, { method: "POST", headers, body: fd, signal: opts.signal });
  124 + const ct = res.headers.get("content-type") ?? "";
  125 + const payload = ct.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => "");
  126 + if (!res.ok) {
  127 + const msg =
  128 + (typeof payload === "object" && payload && getAbpErrorMessage(payload)) ||
  129 + (typeof payload === "string" && payload.trim()) ||
  130 + "Upload failed.";
  131 + throw new ApiError(msg, res.status, payload);
  132 + }
  133 + if (typeof payload !== "object" || payload === null) {
  134 + throw new ApiError("Invalid import response.", res.status, payload);
  135 + }
  136 + return unwrapEnvelope<T>(payload);
  137 +}
美国版/Food Labeling Management Platform/src/lib/labelFormDatePreview.ts
@@ -98,6 +98,24 @@ export function formatDateByPreset(format: string, date: Date): string { @@ -98,6 +98,24 @@ export function formatDateByPreset(format: string, date: Date): string {
98 98
99 const OFFSET_UNIT_SET = new Set<string>(LABEL_FORM_OFFSET_UNITS as unknown as string[]); 99 const OFFSET_UNIT_SET = new Set<string>(LABEL_FORM_OFFSET_UNITS as unknown as string[]);
100 100
  101 +export type NormalizedLabelFormOffset =
  102 + | { kind: "zero" }
  103 + | { kind: "amount"; amount: number; storeValue: string }
  104 + | { kind: "invalid" };
  105 +
  106 +/**
  107 + * 创建/编辑标签表单:日期时间类偏移的**空串**或**数值 0**(含 0、0.0、+0 等)均视为相对基准偏移 0;
  108 + * 预览即「当前基准时刻」;落库建议用 {@link serializePrintInputOffset}(unit, "0")。
  109 + */
  110 +export function normalizeLabelFormOffsetInput(valueRaw: string | undefined): NormalizedLabelFormOffset {
  111 + const t = String(valueRaw ?? "").trim();
  112 + if (t === "") return { kind: "zero" };
  113 + const n = Number(t);
  114 + if (!Number.isFinite(n)) return { kind: "invalid" };
  115 + if (n === 0) return { kind: "zero" };
  116 + return { kind: "amount", amount: n, storeValue: t };
  117 +}
  118 +
101 /** 119 /**
102 * 模板 productDefault 中日期/时间/时长录入:存 `{"unit":"Days","value":"2"}`,供 App 与打印按 BaseTime 解析。 120 * 模板 productDefault 中日期/时间/时长录入:存 `{"unit":"Days","value":"2"}`,供 App 与打印按 BaseTime 解析。
103 */ 121 */
@@ -146,9 +164,10 @@ export function resolveStoredPrintValueToDisplayText( @@ -146,9 +164,10 @@ export function resolveStoredPrintValueToDisplayText(
146 if (!isDateTimeDataEntryField(el)) return s; 164 if (!isDateTimeDataEntryField(el)) return s;
147 const parsed = tryParsePrintInputOffsetStored(s); 165 const parsed = tryParsePrintInputOffsetStored(s);
148 if (!parsed) return s; 166 if (!parsed) return s;
149 - const amount = Number(String(parsed.value).trim());  
150 const unit = parsed.unit || "Days"; 167 const unit = parsed.unit || "Days";
151 - if (!Number.isFinite(amount) || String(parsed.value).trim() === "") return ""; 168 + const norm = normalizeLabelFormOffsetInput(parsed.value);
  169 + if (norm.kind === "invalid") return s;
  170 + const amount = norm.kind === "zero" ? 0 : norm.amount;
152 const type = canonicalElementType(el.type); 171 const type = canonicalElementType(el.type);
153 const d = applyOffsetToDate(base, amount, unit); 172 const d = applyOffsetToDate(base, amount, unit);
154 const cfg = el.config as Record<string, unknown>; 173 const cfg = el.config as Record<string, unknown>;
美国版/Food Labeling Management Platform/src/lib/nutritionManualEntry.ts 0 → 100644
  1 +import type { LabelElement } from "../types/labelTemplate";
  2 +import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate";
  3 +
  4 +/** 批量表 / 与 elementId 拼接的字段名分隔(避免与普通 element id 冲突) */
  5 +export const NUTRITION_FIELD_COMPOSITE_SEP = "###nut###";
  6 +
  7 +export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: string): string {
  8 + return `${nutritionElementId}${NUTRITION_FIELD_COMPOSITE_SEP}${subKey}`;
  9 +}
  10 +
  11 +export type NutritionManualFieldSpec = {
  12 + subKey: string;
  13 + columnLabel: string;
  14 +};
  15 +
  16 +function nutritionExtraRowsFromCfg(cfg: Record<string, unknown>): Array<{
  17 + id: string;
  18 + name: string;
  19 + value: string;
  20 + unit: string;
  21 +}> {
  22 + const raw = cfg.extraNutrients;
  23 + if (!Array.isArray(raw)) return [];
  24 + return raw.map((item, idx) => {
  25 + const row = item as Record<string, unknown>;
  26 + return {
  27 + id: String(row.id ?? `extra-${idx}`),
  28 + name: String(row.name ?? ""),
  29 + value: String(row.value ?? ""),
  30 + unit: String(row.unit ?? ""),
  31 + };
  32 + });
  33 +}
  34 +
  35 +function fixedLabelForKey(key: string): string {
  36 + const hit = NUTRITION_FIXED_ITEMS.find((x) => x.key === key);
  37 + return hit?.label ?? key;
  38 +}
  39 +
  40 +function readFixedValueFromCfg(cfg: Record<string, unknown>, key: string): string {
  41 + const direct = cfg[key];
  42 + if (direct != null && String(direct).trim() !== "") return String(direct).trim();
  43 + const fixedRows = Array.isArray(cfg.fixedNutrients)
  44 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  45 + : [];
  46 + const row = fixedRows.find((item) => String(item.key ?? "").trim() === key);
  47 + return String(row?.value ?? "").trim();
  48 +}
  49 +
  50 +/** 与 LabelCanvas 营养成分展示一致:Calories 行是否应出现 */
  51 +function templateCaloriesDisplay(cfg: Record<string, unknown>): string {
  52 + return String(cfg.calories ?? cfg.Calories ?? readFixedValueFromCfg(cfg, "calories") ?? "").trim();
  53 +}
  54 +
  55 +function templateServingsPerContainer(cfg: Record<string, unknown>): string {
  56 + return String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? "").trim();
  57 +}
  58 +
  59 +function templateServingSize(cfg: Record<string, unknown>): string {
  60 + return String(cfg.servingSize ?? cfg.ServingSize ?? "").trim();
  61 +}
  62 +
  63 +function fakeNutritionElement(cfg: Record<string, unknown>): LabelElement {
  64 + return {
  65 + id: "__nutrition_cfg__",
  66 + type: "NUTRITION",
  67 + x: 0,
  68 + y: 0,
  69 + width: 1,
  70 + height: 1,
  71 + rotation: "horizontal",
  72 + border: "none",
  73 + config: cfg,
  74 + } as LabelElement;
  75 +}
  76 +
  77 +/**
  78 + * 模板中每个 NUTRITION 元素在「录入 / 批量表」中展开的列(表头为营养成分名称)。
  79 + * 仅包含模板里已配置非空展示值的项,与画布预览(LabelCanvas)营养成分表一致。
  80 + */
  81 +export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] {
  82 + if (canonicalElementType(el.type) !== "NUTRITION") return [];
  83 + const cfg = (el.config ?? {}) as Record<string, unknown>;
  84 + const specs: NutritionManualFieldSpec[] = [];
  85 +
  86 + if (templateCaloriesDisplay(cfg)) {
  87 + specs.push({ subKey: "calories", columnLabel: "Calories" });
  88 + }
  89 + if (templateServingsPerContainer(cfg)) {
  90 + specs.push({ subKey: "servingsPerContainer", columnLabel: "Servings Per Container" });
  91 + }
  92 + if (templateServingSize(cfg)) {
  93 + specs.push({ subKey: "servingSize", columnLabel: "Serving Size" });
  94 + }
  95 +
  96 + const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : [];
  97 + const seen = new Set<string>();
  98 + if (fixedArr.length > 0) {
  99 + for (const row of fixedArr) {
  100 + const key = String(row.key ?? "").trim();
  101 + if (!key || seen.has(key)) continue;
  102 + const v = String(row.value ?? "").trim();
  103 + if (!v) continue;
  104 + seen.add(key);
  105 + const label = String(row.label ?? "").trim() || fixedLabelForKey(key);
  106 + specs.push({ subKey: key, columnLabel: label });
  107 + }
  108 + } else {
  109 + for (const item of NUTRITION_FIXED_ITEMS) {
  110 + const v = readFixedValueFromCfg(cfg, item.key).trim();
  111 + if (!v) continue;
  112 + specs.push({ subKey: item.key, columnLabel: item.label });
  113 + }
  114 + }
  115 +
  116 + for (const ex of nutritionExtraRowsFromCfg(cfg)) {
  117 + const id = String(ex.id ?? "").trim();
  118 + if (!id) continue;
  119 + const value = String(ex.value ?? "").trim();
  120 + if (!value) continue;
  121 + const name = ex.name.trim() || "Other";
  122 + specs.push({ subKey: `extra:${id}:value`, columnLabel: name });
  123 + }
  124 + return specs;
  125 +}
  126 +
  127 +export function listNutritionElements(elements: LabelElement[]): LabelElement[] {
  128 + return (elements ?? []).filter((el) => canonicalElementType(el.type) === "NUTRITION");
  129 +}
  130 +
  131 +/** 从模板 config 初始化手动录入 map */
  132 +export function nutritionManualValuesFromTemplateConfig(el: LabelElement): Record<string, string> {
  133 + const cfg = (el.config ?? {}) as Record<string, unknown>;
  134 + const out: Record<string, string> = {};
  135 + const specs = listNutritionManualFieldSpecs(el);
  136 + for (const s of specs) {
  137 + if (s.subKey === "calories") {
  138 + out.calories = String(cfg.calories ?? cfg.Calories ?? readFixedValueFromCfg(cfg, "calories") ?? "").trim();
  139 + continue;
  140 + }
  141 + if (s.subKey === "servingsPerContainer") {
  142 + out.servingsPerContainer = String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? "").trim();
  143 + continue;
  144 + }
  145 + if (s.subKey === "servingSize") {
  146 + out.servingSize = String(cfg.servingSize ?? cfg.ServingSize ?? "").trim();
  147 + continue;
  148 + }
  149 + if (s.subKey.startsWith("extra:") && s.subKey.endsWith(":value")) {
  150 + const id = s.subKey.slice("extra:".length, -":value".length);
  151 + const ex = nutritionExtraRowsFromCfg(cfg).find((r) => r.id === id);
  152 + out[s.subKey] = (ex?.value ?? "").trim();
  153 + continue;
  154 + }
  155 + out[s.subKey] = readFixedValueFromCfg(cfg, s.subKey);
  156 + }
  157 + return out;
  158 +}
  159 +
  160 +function pickManual(
  161 + manual: Record<string, string>,
  162 + subKey: string,
  163 + baseCfg: Record<string, unknown>,
  164 +): string {
  165 + const m = String(manual[subKey] ?? "").trim();
  166 + if (m !== "") return m;
  167 + if (subKey === "calories") {
  168 + return String(baseCfg.calories ?? baseCfg.Calories ?? readFixedValueFromCfg(baseCfg, "calories") ?? "").trim();
  169 + }
  170 + if (subKey === "servingsPerContainer") {
  171 + return String(baseCfg.servingsPerContainer ?? baseCfg.ServingsPerContainer ?? "").trim();
  172 + }
  173 + if (subKey === "servingSize") {
  174 + return String(baseCfg.servingSize ?? baseCfg.ServingSize ?? "").trim();
  175 + }
  176 + if (subKey.startsWith("extra:") && subKey.endsWith(":value")) {
  177 + const id = subKey.slice("extra:".length, -":value".length);
  178 + const ex = nutritionExtraRowsFromCfg(baseCfg).find((r) => r.id === id);
  179 + return (ex?.value ?? "").trim();
  180 + }
  181 + return readFixedValueFromCfg(baseCfg, subKey);
  182 +}
  183 +
  184 +/**
  185 + * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。
  186 + * 输出中仅保留模板已声明的营养成分行,与 listNutritionManualFieldSpecs 一致。
  187 + */
  188 +export function mergeNutritionManualIntoConfig(
  189 + baseCfg: Record<string, unknown>,
  190 + manual: Record<string, string>,
  191 +): Record<string, unknown> {
  192 + const cfg: Record<string, unknown> = { ...baseCfg };
  193 + const specs = listNutritionManualFieldSpecs(fakeNutritionElement(baseCfg));
  194 + const specSubKeys = new Set(specs.map((s) => s.subKey));
  195 +
  196 + if (specSubKeys.has("calories")) {
  197 + const cal = pickManual(manual, "calories", baseCfg);
  198 + if (cal) cfg.calories = cal;
  199 + else {
  200 + delete cfg.calories;
  201 + delete cfg.Calories;
  202 + }
  203 + } else {
  204 + delete cfg.calories;
  205 + delete cfg.Calories;
  206 + }
  207 +
  208 + if (specSubKeys.has("servingsPerContainer")) {
  209 + cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer", baseCfg);
  210 + } else {
  211 + cfg.servingsPerContainer = "";
  212 + delete cfg.ServingsPerContainer;
  213 + }
  214 +
  215 + if (specSubKeys.has("servingSize")) {
  216 + cfg.servingSize = pickManual(manual, "servingSize", baseCfg);
  217 + } else {
  218 + cfg.servingSize = "";
  219 + delete cfg.ServingSize;
  220 + }
  221 +
  222 + const baseFixed = Array.isArray(baseCfg.fixedNutrients)
  223 + ? (baseCfg.fixedNutrients as Record<string, unknown>[])
  224 + : [];
  225 + const fixedArr: Record<string, unknown>[] = [];
  226 + for (const s of specs) {
  227 + if (["calories", "servingsPerContainer", "servingSize"].includes(s.subKey)) continue;
  228 + if (s.subKey.startsWith("extra:")) continue;
  229 + const v = pickManual(manual, s.subKey, baseCfg);
  230 + const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === s.subKey);
  231 + const unit = String(
  232 + baseRow?.unit ?? NUTRITION_FIXED_ITEMS.find((x) => x.key === s.subKey)?.defaultUnit ?? "",
  233 + );
  234 + const label = String(baseRow?.label ?? fixedLabelForKey(s.subKey));
  235 + fixedArr.push({ key: s.subKey, label, value: v, unit });
  236 + }
  237 + cfg.fixedNutrients = fixedArr;
  238 +
  239 + const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = [];
  240 + for (const s of specs) {
  241 + if (!s.subKey.startsWith("extra:") || !s.subKey.endsWith(":value")) continue;
  242 + const id = s.subKey.slice("extra:".length, -":value".length);
  243 + const base = nutritionExtraRowsFromCfg(baseCfg).find((r) => r.id === id);
  244 + newExtras.push({
  245 + id,
  246 + name: (base?.name ?? s.columnLabel).trim() || "Other",
  247 + value: pickManual(manual, s.subKey, baseCfg),
  248 + unit: String(base?.unit ?? "").trim(),
  249 + });
  250 + }
  251 + cfg.extraNutrients = newExtras;
  252 + return cfg;
  253 +}
  254 +
  255 +export function serializeNutritionManualForDefaults(manual: Record<string, string>): string {
  256 + const o: Record<string, string> = {};
  257 + for (const [k, v] of Object.entries(manual)) {
  258 + const t = String(v ?? "").trim();
  259 + if (t !== "") o[k] = t;
  260 + }
  261 + return JSON.stringify(o);
  262 +}
  263 +
  264 +export function parseNutritionManualFromDefaults(raw: string | undefined): Record<string, string> {
  265 + const t = String(raw ?? "").trim();
  266 + if (!t.startsWith("{")) return {};
  267 + try {
  268 + const p = JSON.parse(t) as unknown;
  269 + if (p == null || typeof p !== "object" || Array.isArray(p)) return {};
  270 + const out: Record<string, string> = {};
  271 + for (const [k, v] of Object.entries(p as Record<string, unknown>)) {
  272 + out[k] = String(v ?? "");
  273 + }
  274 + return out;
  275 + } catch {
  276 + return {};
  277 + }
  278 +}
  279 +
  280 +export function nutritionDefaultValuesJsonForSave(manual: Record<string, string>): string | null {
  281 + const json = serializeNutritionManualForDefaults(manual);
  282 + return json === "{}" ? null : json;
  283 +}
  284 +
  285 +/** 从接口 defaultValues 展开营养成分 JSON 为批量表 composite 列键 */
  286 +export function hydrateRowFieldValuesWithNutritionColumns(
  287 + defaultValues: Record<string, string>,
  288 + elements: LabelElement[],
  289 +): Record<string, string> {
  290 + const out = { ...defaultValues };
  291 + for (const nel of listNutritionElements(elements)) {
  292 + const raw = out[nel.id];
  293 + if (typeof raw !== "string" || !raw.trim().startsWith("{")) continue;
  294 + const parsed = parseNutritionManualFromDefaults(raw);
  295 + delete out[nel.id];
  296 + const allowed = new Set(listNutritionManualFieldSpecs(nel).map((s) => s.subKey));
  297 + for (const [sk, val] of Object.entries(parsed)) {
  298 + if (!allowed.has(sk)) continue;
  299 + out[nutritionCompositeFieldKey(nel.id, sk)] = val;
  300 + }
  301 + }
  302 + return out;
  303 +}
  304 +
  305 +/** 将批量表 composite 列折叠回 defaultValues(元素 id → JSON) */
  306 +export function foldNutritionCompositeKeysIntoDefaults(
  307 + fieldValues: Record<string, string>,
  308 + elements: LabelElement[],
  309 +): Record<string, string> {
  310 + const out: Record<string, string> = { ...fieldValues };
  311 + for (const nel of listNutritionElements(elements)) {
  312 + const specs = listNutritionManualFieldSpecs(nel);
  313 + const manual: Record<string, string> = {};
  314 + for (const s of specs) {
  315 + const ck = nutritionCompositeFieldKey(nel.id, s.subKey);
  316 + if (Object.prototype.hasOwnProperty.call(fieldValues, ck)) {
  317 + manual[s.subKey] = fieldValues[ck] ?? "";
  318 + }
  319 + }
  320 + const prefix = `${nel.id}${NUTRITION_FIELD_COMPOSITE_SEP}`;
  321 + for (const k of Object.keys(out)) {
  322 + if (k.startsWith(prefix)) delete out[k];
  323 + }
  324 + const j = nutritionDefaultValuesJsonForSave(manual);
  325 + if (j) out[nel.id] = j;
  326 + else delete out[nel.id];
  327 + }
  328 + return out;
  329 +}
美国版/Food Labeling Management Platform/src/main.tsx
1 1
2 import { createRoot } from "react-dom/client"; 2 import { createRoot } from "react-dom/client";
3 import App from "./App.tsx"; 3 import App from "./App.tsx";
  4 + import "react-day-picker/dist/style.css";
4 import "./index.css"; 5 import "./index.css";
5 import "./styles/fonts.css"; 6 import "./styles/fonts.css";
6 7
美国版/Food Labeling Management Platform/src/services/locationService.ts
1 import { createApiClient } from "../lib/apiClient"; 1 import { createApiClient } from "../lib/apiClient";
  2 +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
2 import type { 3 import type {
3 LocationCreateInput, 4 LocationCreateInput,
4 LocationDto, 5 LocationDto,
@@ -106,3 +107,89 @@ export async function deleteLocation(id: string): Promise&lt;void&gt; { @@ -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 import { createApiClient } from "../lib/apiClient"; 1 import { createApiClient } from "../lib/apiClient";
  2 +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
2 import type { 3 import type {
3 ProductCreateInput, 4 ProductCreateInput,
4 ProductDto, 5 ProductDto,
@@ -116,3 +117,70 @@ export async function deleteProduct(id: string): Promise&lt;void&gt; { @@ -116,3 +117,70 @@ export async function deleteProduct(id: string): Promise&lt;void&gt; {
116 method: "DELETE", 117 method: "DELETE",
117 }); 118 });
118 } 119 }
  120 +
  121 +export type ProductExportQueryInput = {
  122 + keyword?: string;
  123 + state?: boolean;
  124 + sorting?: string;
  125 +};
  126 +
  127 +export type ProductBatchImportResultDto = {
  128 + successCount: number;
  129 + failCount: number;
  130 + errors?: Array<{ rowNumber?: number; productName?: string; message?: string }>;
  131 +};
  132 +
  133 +export type ProductBulkUpdateItemVo = {
  134 + id: string;
  135 + productCode?: string | null;
  136 + productName: string;
  137 + categoryId?: string | null;
  138 + productImageUrl?: string | null;
  139 + state?: boolean;
  140 + locationIds?: string[];
  141 +};
  142 +
  143 +export type ProductBulkUpdateResultDto = {
  144 + successCount: number;
  145 + failCount: number;
  146 + errors?: Array<{ rowNumber?: number; id?: string; message?: string }>;
  147 +};
  148 +
  149 +export async function downloadProductImportTemplate(signal?: AbortSignal): Promise<void> {
  150 + await authorizedGetBlobDownload({
  151 + path: `${PATH}/download-product-import-template`,
  152 + defaultFileName: "Product-Manager-template.xlsx",
  153 + signal,
  154 + });
  155 +}
  156 +
  157 +export async function exportProductsExcel(input: ProductExportQueryInput, signal?: AbortSignal): Promise<void> {
  158 + await authorizedGetBlobDownload({
  159 + path: `${PATH}/export-products-excel`,
  160 + query: {
  161 + Keyword: input.keyword,
  162 + State: input.state,
  163 + Sorting: input.sorting,
  164 + },
  165 + defaultFileName: "products-export.xlsx",
  166 + signal,
  167 + });
  168 +}
  169 +
  170 +export async function importProductsBatch(file: File, signal?: AbortSignal): Promise<ProductBatchImportResultDto> {
  171 + return authorizedPostMultipartJson<ProductBatchImportResultDto>({
  172 + path: `${PATH}/import-products-batch`,
  173 + fieldName: "file",
  174 + file,
  175 + signal,
  176 + });
  177 +}
  178 +
  179 +export async function updateProductsBulk(body: { items: ProductBulkUpdateItemVo[] }): Promise<ProductBulkUpdateResultDto> {
  180 + // ABP 约定控制器:`Update*Async` 多为 PUT,POST 会 405
  181 + return api.requestJson<ProductBulkUpdateResultDto>({
  182 + path: `${PATH}/update-products-bulk`,
  183 + method: "PUT",
  184 + body,
  185 + });
  186 +}
美国版/Food Labeling Management Platform/src/services/reportsService.ts
1 import { ApiError, createApiClient } from "../lib/apiClient"; 1 import { ApiError, createApiClient } from "../lib/apiClient";
  2 +import { authorizedGetBlobDownload } from "../lib/batchFileHttp";
2 import type { 3 import type {
3 LabelReportData, 4 LabelReportData,
4 LabelReportQueryInput, 5 LabelReportQueryInput,
@@ -385,6 +386,23 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi @@ -385,6 +386,23 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi
385 URL.revokeObjectURL(url); 386 URL.revokeObjectURL(url);
386 } 387 }
387 388
  389 +export async function exportPrintLogExcel(input: ReportsPrintLogQueryInput, signal?: AbortSignal): Promise<void> {
  390 + await authorizedGetBlobDownload({
  391 + path: `${REPORTS_PREFIX}/export-print-log-excel`,
  392 + query: {
  393 + Sorting: input.sorting ?? "PrintedAt desc",
  394 + PartnerId: input.partnerId,
  395 + GroupId: input.groupId,
  396 + LocationId: input.locationId,
  397 + StartDate: input.startDate,
  398 + EndDate: input.endDate,
  399 + Keyword: input.keyword,
  400 + },
  401 + defaultFileName: "print-log-export.xlsx",
  402 + signal,
  403 + });
  404 +}
  405 +
388 export async function exportLabelReportPdf(input: LabelReportQueryInput): Promise<void> { 406 export async function exportLabelReportPdf(input: LabelReportQueryInput): Promise<void> {
389 const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; 407 const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
390 const path = joinUrl( 408 const path = joinUrl(
美国版/Food Labeling Management Platform/src/services/roleService.ts
1 -import { createApiClient } from "../lib/apiClient"; 1 +import { ApiError, createApiClient } from "../lib/apiClient";
2 import type { PagedResultDto, RoleDto, RoleGetListInput } from "../types/role"; 2 import type { PagedResultDto, RoleDto, RoleGetListInput } from "../types/role";
3 3
4 const api = createApiClient({ 4 const api = createApiClient({
@@ -29,3 +29,74 @@ export async function getRoles(input: RoleGetListInput, signal?: AbortSignal): P @@ -29,3 +29,74 @@ export async function getRoles(input: RoleGetListInput, signal?: AbortSignal): P
29 }); 29 });
30 } 30 }
31 31
  32 +function getBaseUrl(): string {
  33 + return import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
  34 +}
  35 +
  36 +function getToken(): string | null {
  37 + try {
  38 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  39 + } catch {
  40 + return null;
  41 + }
  42 +}
  43 +
  44 +function joinUrl(baseUrl: string, path: string): string {
  45 + const b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
  46 + const p = path.startsWith("/") ? path : `/${path}`;
  47 + return `${b}${p}`;
  48 +}
  49 +
  50 +function toQueryString(params: Record<string, unknown>): string {
  51 + const qs = new URLSearchParams();
  52 + for (const [k, v] of Object.entries(params)) {
  53 + if (v === undefined || v === null || v === "") continue;
  54 + if (typeof v === "boolean") {
  55 + qs.set(k, v ? "true" : "false");
  56 + continue;
  57 + }
  58 + qs.set(k, String(v));
  59 + }
  60 + const s = qs.toString();
  61 + return s ? `?${s}` : "";
  62 +}
  63 +
  64 +export type RoleExportQuery = {
  65 + roleName?: string;
  66 + roleCode?: string;
  67 + state?: boolean;
  68 +};
  69 +
  70 +/** GET /api/app/role/export-pdf — 与角色列表筛选字段一致 */
  71 +export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSignal): Promise<Blob> {
  72 + const baseUrl = getBaseUrl();
  73 + const token = getToken();
  74 + const url = joinUrl(
  75 + baseUrl,
  76 + `/api/app/role/export-pdf${toQueryString({
  77 + RoleName: input.roleName,
  78 + RoleCode: input.roleCode,
  79 + State: input.state,
  80 + })}`,
  81 + );
  82 + const headers: Record<string, string> = {};
  83 + if (token) headers.Authorization = `Bearer ${token}`;
  84 + const res = await fetch(url, { method: "GET", headers, signal });
  85 + if (!res.ok) {
  86 + const ct = res.headers.get("content-type") ?? "";
  87 + let msg = "Export failed.";
  88 + if (ct.includes("application/json")) {
  89 + const payload = await res.json().catch(() => null);
  90 + const m =
  91 + (payload as { error?: { message?: string } })?.error?.message?.trim() ||
  92 + (payload as { message?: string })?.message?.trim();
  93 + if (m) msg = m;
  94 + } else {
  95 + const t = await res.text().catch(() => "");
  96 + if (t.trim()) msg = t.trim();
  97 + }
  98 + throw new ApiError(msg, res.status, null);
  99 + }
  100 + return res.blob();
  101 +}
  102 +
美国版/Food Labeling Management Platform/src/services/teamMemberService.ts
1 import { createApiClient } from "../lib/apiClient"; 1 import { createApiClient } from "../lib/apiClient";
  2 +import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
2 import type { 3 import type {
3 PagedResultDto, 4 PagedResultDto,
4 TeamMemberCreateInput, 5 TeamMemberCreateInput,
@@ -152,6 +153,10 @@ export async function getTeamMembers( @@ -152,6 +153,10 @@ export async function getTeamMembers(
152 SkipCount: input.skipCount, 153 SkipCount: input.skipCount,
153 MaxResultCount: input.maxResultCount, 154 MaxResultCount: input.maxResultCount,
154 Keyword: input.keyword, 155 Keyword: input.keyword,
  156 + RoleId: input.roleId,
  157 + LocationId: input.locationId,
  158 + State: input.state,
  159 + Sorting: input.sorting,
155 }, 160 },
156 signal, 161 signal,
157 }); 162 });
@@ -238,3 +243,79 @@ export async function deleteTeamMember(id: string): Promise&lt;void&gt; { @@ -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,19 +600,22 @@ export function isBlankSpaceElement(el: LabelElement): boolean {
600 } 600 }
601 601
602 /** 602 /**
603 - * 录入数据表格:仅展示「元素面板红框」对应的 typeAdd(Template / Label 分组),  
604 - * 且必须满足 valueSourceType === FIXED 才可录入。  
605 - * Print input、Auto-generated、未在红框内的控件(如 Nutrition Facts、Blank Space)不进入表格。 603 + * 左侧 **Template** 面板拖入的元素(持久化 typeAdd / type 以 `template_` 开头)。
  604 + * 其文案/图片等在模板编辑器内固化,不参与「按产品绑定默认值」表与新建标签的中间录入列。
  605 + */
  606 +export function isTemplateSectionPersistedType(el: LabelElement): boolean {
  607 + return resolvedTypeAddForPersist(el).trim().toLowerCase().startsWith("template_");
  608 +}
  609 +
  610 +/**
  611 + * 录入数据表格:仅展示 **Label** 分组等需在「产品×标签类型」行上维护默认值的列;
  612 + * **Template** 分组控件在编辑器内保存,此处不展示。
  613 + * Print input、Auto-generated、Nutrition(拆列)、Blank Space 等按原规则处理。
606 */ 614 */
607 export function isDataEntryTableColumnElement(el: LabelElement): boolean { 615 export function isDataEntryTableColumnElement(el: LabelElement): boolean {
608 const persistedType = resolvedTypeAddForPersist(el).trim().toLowerCase(); 616 const persistedType = resolvedTypeAddForPersist(el).trim().toLowerCase();
  617 + if (isTemplateSectionPersistedType(el)) return false;
609 const manualTypeAddWhitelist = new Set([ 618 const manualTypeAddWhitelist = new Set([
610 - "template_text",  
611 - "template_qr code",  
612 - "template_barcode",  
613 - "template_price",  
614 - "template_logo",  
615 - "template_image",  
616 "label_label name", 619 "label_label name",
617 "label_text", 620 "label_text",
618 "label_qr code", 621 "label_qr code",
@@ -626,7 +629,6 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean { @@ -626,7 +629,6 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean {
626 "label_how-to", 629 "label_how-to",
627 "label_expiration alert", 630 "label_expiration alert",
628 ]); 631 ]);
629 - const type = canonicalElementType(el.type);  
630 const vst = normalizeValueSourceTypeForElement(el); 632 const vst = normalizeValueSourceTypeForElement(el);
631 if (isBlankSpaceElement(el)) return false; 633 if (isBlankSpaceElement(el)) return false;
632 if (!manualTypeAddWhitelist.has(persistedType)) return false; 634 if (!manualTypeAddWhitelist.has(persistedType)) return false;
美国版/Food Labeling Management Platform/src/types/teamMember.ts
@@ -27,6 +27,10 @@ export type TeamMemberGetListInput = { @@ -27,6 +27,10 @@ export type TeamMemberGetListInput = {
27 skipCount: number; // pageIndex (1-based) 27 skipCount: number; // pageIndex (1-based)
28 maxResultCount: number; // pageSize 28 maxResultCount: number; // pageSize
29 keyword?: string; 29 keyword?: string;
  30 + roleId?: string;
  31 + locationId?: string;
  32 + state?: boolean;
  33 + sorting?: string;
30 }; 34 };
31 35
32 export type TeamMemberCreateInput = { 36 export type TeamMemberCreateInput = {