Compare commits
23 Commits
refactor
...
deploy-202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f564f944f7 | ||
|
|
629b9c1756 | ||
|
|
c5a7666ce6 | ||
|
|
70b7713f2e | ||
|
|
da79307461 | ||
|
|
3e4f576fd5 | ||
|
|
b24a1fea27 | ||
|
|
93df263214 | ||
|
|
e50f361b07 | ||
|
|
9594ce56cb | ||
|
|
11dbea3536 | ||
|
|
6d04e16cf6 | ||
|
|
85481512c8 | ||
|
|
1e95455139 | ||
|
|
f929c384c1 | ||
|
|
291997caa5 | ||
|
|
0741bd0d0e | ||
|
|
746f0925d0 | ||
|
|
2985ea4457 | ||
|
|
f2ae608b01 | ||
|
|
65ae493d54 | ||
|
|
ff6abdeef0 | ||
|
|
e9afe8bc35 |
@@ -13,65 +13,100 @@ env:
|
|||||||
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
|
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
# ── Job 1: Change Detection ──────────────────────────────────────────
|
||||||
|
detect:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
frontend: ${{ steps.changes.outputs.frontend }}
|
||||||
|
api: ${{ steps.changes.outputs.api }}
|
||||||
|
migrations: ${{ steps.changes.outputs.migrations }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Detect changes
|
- name: Detect changes since last deploy
|
||||||
id: changes
|
id: changes
|
||||||
run: |
|
run: |
|
||||||
FRONTEND_CHANGED=false
|
# Find the latest deploy tag
|
||||||
API_CHANGED=false
|
LAST_TAG=$(git tag -l 'deploy-*' --sort=-creatordate | head -n 1)
|
||||||
|
|
||||||
if git diff --name-only HEAD~1 HEAD | grep -qvE '^api/'; then
|
if [ -z "$LAST_TAG" ]; then
|
||||||
FRONTEND_CHANGED=true
|
echo "No deploy tag found — deploying everything"
|
||||||
|
echo "frontend=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "api=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "migrations=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if git diff --name-only HEAD~1 HEAD | grep -qE '^api/'; then
|
echo "Last deploy tag: $LAST_TAG"
|
||||||
API_CHANGED=true
|
DIFF_FILES=$(git diff --name-only "$LAST_TAG"..HEAD)
|
||||||
|
echo "Changed files since $LAST_TAG:"
|
||||||
|
echo "$DIFF_FILES"
|
||||||
|
|
||||||
|
FRONTEND=false
|
||||||
|
API=false
|
||||||
|
MIGRATIONS=false
|
||||||
|
|
||||||
|
if echo "$DIFF_FILES" | grep -qvE '^(api/|database/)'; then
|
||||||
|
FRONTEND=true
|
||||||
|
fi
|
||||||
|
if echo "$DIFF_FILES" | grep -qE '^api/'; then
|
||||||
|
API=true
|
||||||
|
fi
|
||||||
|
if echo "$DIFF_FILES" | grep -qE '^database/migrations/'; then
|
||||||
|
MIGRATIONS=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "frontend=$FRONTEND_CHANGED" >> $GITHUB_OUTPUT
|
echo "frontend=$FRONTEND" >> $GITHUB_OUTPUT
|
||||||
echo "api=$API_CHANGED" >> $GITHUB_OUTPUT
|
echo "api=$API" >> $GITHUB_OUTPUT
|
||||||
echo "Frontend changed: $FRONTEND_CHANGED"
|
echo "migrations=$MIGRATIONS" >> $GITHUB_OUTPUT
|
||||||
echo "API changed: $API_CHANGED"
|
echo "Frontend: $FRONTEND | API: $API | Migrations: $MIGRATIONS"
|
||||||
|
|
||||||
|
# ── Job 2: Frontend Deploy ───────────────────────────────────────────
|
||||||
|
deploy-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.frontend == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# ── Frontend Deploy ──────────────────────────────────────────────
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Security check & tests
|
- name: Security check & tests
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: |
|
run: |
|
||||||
npm run security:check
|
npm run security:check
|
||||||
npm test -- --passWithNoTests
|
npm test -- --passWithNoTests
|
||||||
|
|
||||||
- name: Build Next.js
|
- name: Build Next.js
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Verify build output
|
||||||
|
run: |
|
||||||
|
if [ ! -d "out" ]; then
|
||||||
|
echo "ERROR: out/ directory not found after build"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Build output verified: $(find out -type f | wc -l) files"
|
||||||
|
|
||||||
- name: Setup AWS
|
- name: Setup AWS
|
||||||
if: steps.changes.outputs.frontend == 'true' || steps.changes.outputs.api == 'true'
|
|
||||||
run: |
|
run: |
|
||||||
pip3 install -q --break-system-packages awscli
|
pip3 install -q --break-system-packages awscli
|
||||||
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
||||||
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
||||||
aws configure set region eu-central-1
|
aws configure set region ${{ env.AWS_REGION }}
|
||||||
|
|
||||||
- name: Deploy to S3 & invalidate CloudFront
|
- name: Deploy to S3 & invalidate CloudFront
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: |
|
run: |
|
||||||
|
# HTML files — no cache
|
||||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
--delete \
|
--delete \
|
||||||
--exclude "*" \
|
--exclude "*" \
|
||||||
@@ -79,9 +114,11 @@ jobs:
|
|||||||
--cache-control "public, max-age=0, must-revalidate" \
|
--cache-control "public, max-age=0, must-revalidate" \
|
||||||
--content-type "text/html"
|
--content-type "text/html"
|
||||||
|
|
||||||
|
# Next.js assets — immutable
|
||||||
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
||||||
--cache-control "public, max-age=31536000, immutable"
|
--cache-control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Everything else — 24h cache
|
||||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
--exclude "*.html" \
|
--exclude "*.html" \
|
||||||
--exclude "_next/*" \
|
--exclude "_next/*" \
|
||||||
@@ -91,20 +128,161 @@ jobs:
|
|||||||
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
|
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
|
||||||
--paths "/*"
|
--paths "/*"
|
||||||
|
|
||||||
# ── API Deploy ───────────────────────────────────────────────────
|
# ── Job 3: API & Migrations Deploy ──────────────────────────────────
|
||||||
- name: Deploy API via SSM
|
deploy-api:
|
||||||
if: steps.changes.outputs.api == 'true'
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.api == 'true' || needs.detect.outputs.migrations == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Setup AWS
|
||||||
run: |
|
run: |
|
||||||
|
pip3 install -q --break-system-packages awscli
|
||||||
|
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
||||||
|
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
||||||
|
aws configure set region ${{ env.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
if: needs.detect.outputs.migrations == 'true'
|
||||||
|
run: |
|
||||||
|
cat > /tmp/migrate-params.json << 'PARAMS'
|
||||||
|
{"commands":["set -e","cd /tmp","rm -rf repo.tar.gz filamenteka","curl -sf -o repo.tar.gz https://git.demirix.dev/dax/Filamenteka/archive/main.tar.gz","tar xzf repo.tar.gz","docker exec filamenteka-api rm -rf /database","docker cp filamenteka/database filamenteka-api:/database","docker cp filamenteka/api/migrate.js filamenteka-api:/app/migrate.js","rm -rf repo.tar.gz filamenteka","docker exec filamenteka-api ls /database/schema.sql /database/migrations/","docker exec -w /app filamenteka-api node migrate.js"]}
|
||||||
|
PARAMS
|
||||||
|
CMD_ID=$(aws ssm send-command \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--instance-ids "$INSTANCE_ID" \
|
||||||
|
--document-name "AWS-RunShellScript" \
|
||||||
|
--parameters file:///tmp/migrate-params.json \
|
||||||
|
--query "Command.CommandId" --output text)
|
||||||
|
|
||||||
|
echo "Migration SSM command: $CMD_ID"
|
||||||
|
|
||||||
|
# Poll for completion (max 2 minutes)
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
STATUS=$(aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||||
|
|
||||||
|
echo "Attempt $i: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "Success" ]; then
|
||||||
|
echo "Migrations completed successfully"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardOutputContent" --output text
|
||||||
|
break
|
||||||
|
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||||
|
echo "Migration failed with status: $STATUS"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardErrorContent" --output text
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "Success" ]; then
|
||||||
|
echo "Migration timed out after 2 minutes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy server.js
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
cat > /tmp/deploy-params.json << 'PARAMS'
|
||||||
|
{"commands":["set -e","docker exec filamenteka-api cp /app/server.js /app/server.js.backup","curl -sf -o /tmp/server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js","docker cp /tmp/server.js filamenteka-api:/app/server.js","rm -f /tmp/server.js","docker restart filamenteka-api","echo API deployed and restarted"]}
|
||||||
|
PARAMS
|
||||||
|
CMD_ID=$(aws ssm send-command \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--instance-ids "$INSTANCE_ID" \
|
||||||
|
--document-name "AWS-RunShellScript" \
|
||||||
|
--parameters file:///tmp/deploy-params.json \
|
||||||
|
--query "Command.CommandId" --output text)
|
||||||
|
|
||||||
|
echo "Deploy SSM command: $CMD_ID"
|
||||||
|
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
STATUS=$(aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||||
|
|
||||||
|
echo "Attempt $i: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "Success" ]; then
|
||||||
|
echo "API deploy completed"
|
||||||
|
break
|
||||||
|
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||||
|
echo "API deploy failed with status: $STATUS"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardErrorContent" --output text
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "Success" ]; then
|
||||||
|
echo "API deploy timed out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
id: health
|
||||||
|
run: |
|
||||||
|
echo "Waiting for API to be ready..."
|
||||||
|
for i in $(seq 1 6); do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api.filamenteka.rs/api/filaments || echo "000")
|
||||||
|
echo "Health check attempt $i: HTTP $HTTP_CODE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "API is healthy"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Health check failed after 6 attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Rollback on failure
|
||||||
|
if: failure() && steps.deploy.outcome == 'success'
|
||||||
|
run: |
|
||||||
|
echo "Rolling back to server.js.backup..."
|
||||||
|
cat > /tmp/rollback-params.json << 'PARAMS'
|
||||||
|
{"commands":["docker exec filamenteka-api sh -c 'if [ -f /app/server.js.backup ]; then cp /app/server.js.backup /app/server.js; fi'","docker restart filamenteka-api","echo Rollback complete"]}
|
||||||
|
PARAMS
|
||||||
aws ssm send-command \
|
aws ssm send-command \
|
||||||
--region $AWS_REGION \
|
--region $AWS_REGION \
|
||||||
--instance-ids "$INSTANCE_ID" \
|
--instance-ids "$INSTANCE_ID" \
|
||||||
--document-name "AWS-RunShellScript" \
|
--document-name "AWS-RunShellScript" \
|
||||||
--parameters 'commands=[
|
--parameters file:///tmp/rollback-params.json \
|
||||||
"cd /home/ubuntu/filamenteka-api",
|
|
||||||
"cp server.js server.js.backup",
|
|
||||||
"curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js",
|
|
||||||
"sudo systemctl restart node-api",
|
|
||||||
"sudo systemctl status node-api"
|
|
||||||
]' \
|
|
||||||
--output json
|
--output json
|
||||||
echo "API deploy command sent via SSM"
|
echo "Rollback command sent"
|
||||||
|
|
||||||
|
# ── Job 4: Tag Successful Deploy ────────────────────────────────────
|
||||||
|
tag-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect, deploy-frontend, deploy-api]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.detect.result == 'success' &&
|
||||||
|
(needs.deploy-frontend.result == 'success' || needs.deploy-frontend.result == 'skipped') &&
|
||||||
|
(needs.deploy-api.result == 'success' || needs.deploy-api.result == 'skipped') &&
|
||||||
|
!(needs.deploy-frontend.result == 'skipped' && needs.deploy-api.result == 'skipped')
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Create deploy tag
|
||||||
|
run: |
|
||||||
|
TAG="deploy-$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
git tag "$TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
echo "Tagged successful deploy: $TAG"
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ describe('Bambu Lab Colors Complete Data', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have matte colors', () => {
|
it('should have matte colors', () => {
|
||||||
expect(bambuLabColors).toHaveProperty('Matte Black');
|
expect(bambuLabColors).toHaveProperty('Matte Ivory White');
|
||||||
expect(bambuLabColors).toHaveProperty('Matte White');
|
expect(bambuLabColors).toHaveProperty('Matte Charcoal');
|
||||||
expect(bambuLabColors).toHaveProperty('Matte Red');
|
expect(bambuLabColors).toHaveProperty('Matte Scarlet Red');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have silk colors', () => {
|
it('should have silk colors', () => {
|
||||||
expect(bambuLabColors).toHaveProperty('Silk White');
|
expect(bambuLabColors).toHaveProperty('Silk Aurora Purple');
|
||||||
expect(bambuLabColors).toHaveProperty('Silk Black');
|
expect(bambuLabColors).toHaveProperty('Silk Phantom Blue');
|
||||||
expect(bambuLabColors).toHaveProperty('Silk Gold');
|
expect(bambuLabColors).toHaveProperty('Silk Mystic Magenta');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have valid hex colors', () => {
|
it('should have valid hex colors', () => {
|
||||||
@@ -40,12 +40,12 @@ describe('Bambu Lab Colors Complete Data', () => {
|
|||||||
it('should have finish categories', () => {
|
it('should have finish categories', () => {
|
||||||
expect(colorsByFinish).toHaveProperty('Basic');
|
expect(colorsByFinish).toHaveProperty('Basic');
|
||||||
expect(colorsByFinish).toHaveProperty('Matte');
|
expect(colorsByFinish).toHaveProperty('Matte');
|
||||||
expect(colorsByFinish).toHaveProperty('Silk');
|
expect(colorsByFinish).toHaveProperty('Silk+');
|
||||||
expect(colorsByFinish).toHaveProperty('Metal');
|
expect(colorsByFinish).toHaveProperty('Metal');
|
||||||
expect(colorsByFinish).toHaveProperty('Sparkle');
|
expect(colorsByFinish).toHaveProperty('Sparkle');
|
||||||
expect(colorsByFinish).toHaveProperty('Glow');
|
expect(colorsByFinish).toHaveProperty('Glow');
|
||||||
expect(colorsByFinish).toHaveProperty('Transparent');
|
expect(colorsByFinish).toHaveProperty('Translucent');
|
||||||
expect(colorsByFinish).toHaveProperty('Support');
|
expect(colorsByFinish).toHaveProperty('CF');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return valid hex for getColorHex', () => {
|
it('should return valid hex for getColorHex', () => {
|
||||||
|
|||||||
@@ -47,20 +47,17 @@ describe('UI Features Tests', () => {
|
|||||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||||
|
|
||||||
// Check for material select dropdown
|
// Check for material select dropdown (now generated from catalog)
|
||||||
expect(adminContent).toContain('<option value="PLA">PLA</option>');
|
expect(adminContent).toContain('getMaterialOptions()');
|
||||||
expect(adminContent).toContain('<option value="PETG">PETG</option>');
|
expect(adminContent).toContain('Izaberi tip');
|
||||||
expect(adminContent).toContain('<option value="ABS">ABS</option>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have admin header with navigation', () => {
|
it('should have admin sidebar and header', () => {
|
||||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
|
|
||||||
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||||
|
|
||||||
// Check for admin header
|
// Check for admin sidebar and header
|
||||||
expect(dashboardContent).toContain('Admin');
|
expect(dashboardContent).toContain('AdminSidebar');
|
||||||
expect(dashboardContent).toContain('Nazad na sajt');
|
|
||||||
expect(dashboardContent).toContain('Odjava');
|
expect(dashboardContent).toContain('Odjava');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
414
api/server.js
414
api/server.js
@@ -385,6 +385,420 @@ app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Customer endpoints (admin-only)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
app.get('/api/customers', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM customers ORDER BY name');
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch customers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/customers/search', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
if (!q) return res.json([]);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM customers WHERE name ILIKE $1 OR phone ILIKE $1 ORDER BY name LIMIT 10`,
|
||||||
|
[`%${q}%`]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching customers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to search customers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/customers/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const customerResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||||
|
if (customerResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
}
|
||||||
|
const salesResult = await pool.query(
|
||||||
|
`SELECT s.*,
|
||||||
|
(SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count
|
||||||
|
FROM sales s WHERE s.customer_id = $1 ORDER BY s.created_at DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ ...customerResult.rows[0], sales: salesResult.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/customers', authenticateToken, async (req, res) => {
|
||||||
|
const { name, phone, city, notes } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||||
|
[name, phone || null, city || null, notes || null]
|
||||||
|
);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'Customer with this phone already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error creating customer:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/customers/:id', authenticateToken, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, phone, city, notes } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE customers SET name = $1, phone = $2, city = $3, notes = $4, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $5 RETURNING *`,
|
||||||
|
[name, phone || null, city || null, notes || null, id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating customer:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Sale endpoints (admin-only)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
app.post('/api/sales', authenticateToken, async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { customer, items, notes } = req.body;
|
||||||
|
|
||||||
|
if (!customer || !customer.name) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'Customer name is required' });
|
||||||
|
}
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'At least one item is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-create customer by phone
|
||||||
|
let customerId;
|
||||||
|
if (customer.phone) {
|
||||||
|
const existing = await client.query(
|
||||||
|
'SELECT id FROM customers WHERE phone = $1', [customer.phone]
|
||||||
|
);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
customerId = existing.rows[0].id;
|
||||||
|
// Update name/city if provided
|
||||||
|
await client.query(
|
||||||
|
`UPDATE customers SET name = $1, city = COALESCE($2, city), notes = COALESCE($3, notes), updated_at = CURRENT_TIMESTAMP WHERE id = $4`,
|
||||||
|
[customer.name, customer.city, customer.notes, customerId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!customerId) {
|
||||||
|
const newCust = await client.query(
|
||||||
|
'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||||
|
[customer.name, customer.phone || null, customer.city || null, customer.notes || null]
|
||||||
|
);
|
||||||
|
customerId = newCust.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process items
|
||||||
|
let totalAmount = 0;
|
||||||
|
const saleItems = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const { filament_id, item_type, quantity } = item;
|
||||||
|
if (!['refill', 'spulna'].includes(item_type)) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: `Invalid item_type: ${item_type}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filament with stock check
|
||||||
|
const filamentResult = await client.query(
|
||||||
|
'SELECT f.*, c.cena_refill, c.cena_spulna FROM filaments f JOIN colors c ON f.boja = c.name WHERE f.id = $1 FOR UPDATE',
|
||||||
|
[filament_id]
|
||||||
|
);
|
||||||
|
if (filamentResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: `Filament ${filament_id} not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filament = filamentResult.rows[0];
|
||||||
|
if (filament[item_type] < quantity) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Insufficient stock for ${filament.boja} ${item_type}: have ${filament[item_type]}, need ${quantity}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine price (apply sale discount if active)
|
||||||
|
let unitPrice = item_type === 'refill' ? (filament.cena_refill || 3499) : (filament.cena_spulna || 3999);
|
||||||
|
if (filament.sale_active && filament.sale_percentage > 0) {
|
||||||
|
unitPrice = Math.round(unitPrice * (1 - filament.sale_percentage / 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement inventory
|
||||||
|
const updateField = item_type === 'refill' ? 'refill' : 'spulna';
|
||||||
|
await client.query(
|
||||||
|
`UPDATE filaments SET ${updateField} = ${updateField} - $1, kolicina = kolicina - $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||||
|
[quantity, filament_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
totalAmount += unitPrice * quantity;
|
||||||
|
saleItems.push({ filament_id, item_type, quantity, unit_price: unitPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert sale
|
||||||
|
const saleResult = await client.query(
|
||||||
|
'INSERT INTO sales (customer_id, total_amount, notes) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
[customerId, totalAmount, notes || null]
|
||||||
|
);
|
||||||
|
const sale = saleResult.rows[0];
|
||||||
|
|
||||||
|
// Insert sale items
|
||||||
|
for (const si of saleItems) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO sale_items (sale_id, filament_id, item_type, quantity, unit_price) VALUES ($1, $2, $3, $4, $5)',
|
||||||
|
[sale.id, si.filament_id, si.item_type, si.quantity, si.unit_price]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Fetch full sale with items
|
||||||
|
const fullSale = await pool.query(
|
||||||
|
`SELECT s.*, c.name as customer_name, c.phone as customer_phone
|
||||||
|
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`,
|
||||||
|
[sale.id]
|
||||||
|
);
|
||||||
|
const fullItems = await pool.query(
|
||||||
|
`SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja
|
||||||
|
FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1`,
|
||||||
|
[sale.id]
|
||||||
|
);
|
||||||
|
res.json({ ...fullSale.rows[0], items: fullItems.rows });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error creating sale:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create sale' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sales', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const countResult = await pool.query('SELECT COUNT(*) FROM sales');
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT s.*, c.name as customer_name, c.phone as customer_phone,
|
||||||
|
(SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count
|
||||||
|
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
ORDER BY s.created_at DESC LIMIT $1 OFFSET $2`,
|
||||||
|
[limit, offset]
|
||||||
|
);
|
||||||
|
res.json({ sales: result.rows, total });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sales:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch sales' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sales/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const saleResult = await pool.query(
|
||||||
|
`SELECT s.*, c.name as customer_name, c.phone as customer_phone, c.city as customer_city
|
||||||
|
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (saleResult.rows.length === 0) return res.status(404).json({ error: 'Sale not found' });
|
||||||
|
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
`SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja
|
||||||
|
FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1 ORDER BY si.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ ...saleResult.rows[0], items: itemsResult.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sale:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch sale' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/sales/:id', authenticateToken, async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Get sale items to restore inventory
|
||||||
|
const itemsResult = await client.query('SELECT * FROM sale_items WHERE sale_id = $1', [id]);
|
||||||
|
if (itemsResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'Sale not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore inventory for each item
|
||||||
|
for (const item of itemsResult.rows) {
|
||||||
|
const updateField = item.item_type === 'refill' ? 'refill' : 'spulna';
|
||||||
|
await client.query(
|
||||||
|
`UPDATE filaments SET ${updateField} = ${updateField} + $1, kolicina = kolicina + $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||||
|
[item.quantity, item.filament_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete sale (cascade deletes sale_items)
|
||||||
|
await client.query('DELETE FROM sales WHERE id = $1', [id]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error deleting sale:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete sale' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Analytics endpoints (admin-only)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function getPeriodInterval(period) {
|
||||||
|
const map = {
|
||||||
|
'7d': '7 days', '30d': '30 days', '90d': '90 days',
|
||||||
|
'6m': '6 months', '1y': '1 year', 'all': '100 years'
|
||||||
|
};
|
||||||
|
return map[period] || '30 days';
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/analytics/overview', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(total_amount), 0) as revenue,
|
||||||
|
COUNT(*) as sales_count,
|
||||||
|
COALESCE(ROUND(AVG(total_amount)), 0) as avg_order_value,
|
||||||
|
COUNT(DISTINCT customer_id) as unique_customers
|
||||||
|
FROM sales WHERE created_at >= NOW() - $1::interval`,
|
||||||
|
[interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics overview:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch analytics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/top-sellers', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT f.boja, f.tip, f.finish,
|
||||||
|
SUM(si.quantity) as total_qty,
|
||||||
|
SUM(si.quantity * si.unit_price) as total_revenue
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN filaments f ON si.filament_id = f.id
|
||||||
|
JOIN sales s ON si.sale_id = s.id
|
||||||
|
WHERE s.created_at >= NOW() - $1::interval
|
||||||
|
GROUP BY f.boja, f.tip, f.finish
|
||||||
|
ORDER BY total_qty DESC LIMIT 10`,
|
||||||
|
[interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching top sellers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch top sellers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/inventory-alerts', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT f.id, f.boja, f.tip, f.finish, f.refill, f.spulna, f.kolicina,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT SUM(si.quantity)::float / GREATEST(EXTRACT(EPOCH FROM (NOW() - MIN(s.created_at))) / 86400, 1)
|
||||||
|
FROM sale_items si JOIN sales s ON si.sale_id = s.id
|
||||||
|
WHERE si.filament_id = f.id AND s.created_at >= NOW() - INTERVAL '90 days'),
|
||||||
|
0
|
||||||
|
) as avg_daily_sales
|
||||||
|
FROM filaments f
|
||||||
|
WHERE f.kolicina <= 5
|
||||||
|
ORDER BY f.kolicina ASC, f.boja`
|
||||||
|
);
|
||||||
|
const rows = result.rows.map(r => ({
|
||||||
|
...r,
|
||||||
|
avg_daily_sales: parseFloat(r.avg_daily_sales) || 0,
|
||||||
|
days_until_stockout: r.avg_daily_sales > 0
|
||||||
|
? Math.round(r.kolicina / r.avg_daily_sales)
|
||||||
|
: null
|
||||||
|
}));
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching inventory alerts:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch inventory alerts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/revenue-chart', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period || '6m');
|
||||||
|
const group = req.query.group || 'month';
|
||||||
|
const truncTo = group === 'day' ? 'day' : group === 'week' ? 'week' : 'month';
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DATE_TRUNC($1, created_at) as period,
|
||||||
|
SUM(total_amount) as revenue,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM sales
|
||||||
|
WHERE created_at >= NOW() - $2::interval
|
||||||
|
GROUP BY DATE_TRUNC($1, created_at)
|
||||||
|
ORDER BY period`,
|
||||||
|
[truncTo, interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching revenue chart:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch revenue chart' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/type-breakdown', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT si.item_type,
|
||||||
|
SUM(si.quantity) as total_qty,
|
||||||
|
SUM(si.quantity * si.unit_price) as total_revenue
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN sales s ON si.sale_id = s.id
|
||||||
|
WHERE s.created_at >= NOW() - $1::interval
|
||||||
|
GROUP BY si.item_type`,
|
||||||
|
[interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching type breakdown:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch type breakdown' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
@@ -7,70 +7,40 @@ import { Filament } from '@/src/types/filament';
|
|||||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||||
import { SaleManager } from '@/src/components/SaleManager';
|
import { SaleManager } from '@/src/components/SaleManager';
|
||||||
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
|
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
|
||||||
// Removed unused imports for Bambu Lab color categorization
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
import {
|
||||||
|
getFinishesForMaterial,
|
||||||
|
getColorsForMaterialFinish,
|
||||||
|
catalogIsSpoolOnly,
|
||||||
|
catalogIsRefillOnly,
|
||||||
|
getMaterialOptions,
|
||||||
|
} from '@/src/data/bambuLabCatalog';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
|
||||||
// Colors that only come as refills (no spools)
|
// Catalog-driven helpers
|
||||||
const REFILL_ONLY_COLORS = [
|
const isSpoolOnly = (finish?: string, type?: string, color?: string): boolean => {
|
||||||
'Beige',
|
if (!type || !finish) return false;
|
||||||
'Light Gray',
|
if (color) return catalogIsSpoolOnly(type, finish, color);
|
||||||
'Yellow',
|
// If no specific color, check if ALL colors in this finish are spool-only
|
||||||
'Orange',
|
const colors = getColorsForMaterialFinish(type, finish);
|
||||||
'Gold',
|
return colors.length > 0 && colors.every(c => c.spool && !c.refill);
|
||||||
'Bright Green',
|
|
||||||
'Pink',
|
|
||||||
'Magenta',
|
|
||||||
'Maroon Red',
|
|
||||||
'Purple',
|
|
||||||
'Turquoise',
|
|
||||||
'Cobalt Blue',
|
|
||||||
'Brown',
|
|
||||||
'Bronze',
|
|
||||||
'Silver',
|
|
||||||
'Blue Grey',
|
|
||||||
'Dark Gray'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper function to check if a filament is spool-only
|
|
||||||
const isSpoolOnly = (finish?: string, type?: string): boolean => {
|
|
||||||
return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || finish === 'Wood' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if a filament should be refill-only
|
|
||||||
const isRefillOnly = (color: string, finish?: string, type?: string): boolean => {
|
const isRefillOnly = (color: string, finish?: string, type?: string): boolean => {
|
||||||
// If the finish/type combination is spool-only, then it's not refill-only
|
if (!type || !finish || !color) return false;
|
||||||
if (isSpoolOnly(finish, type)) {
|
return catalogIsRefillOnly(type, finish, color);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Translucent finish always has spool option
|
|
||||||
if (finish === 'Translucent') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Specific type/finish/color combinations that are refill-only
|
|
||||||
if (type === 'ABS' && finish === 'GF' && (color === 'Yellow' || color === 'Orange')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (type === 'TPU' && finish === '95A HF') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// All colors starting with "Matte " prefix are refill-only
|
|
||||||
if (color.startsWith('Matte ')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Galaxy and Basic colors have spools available (not refill-only)
|
|
||||||
if (finish === 'Galaxy' || finish === 'Basic') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return REFILL_ONLY_COLORS.includes(color);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to filter colors based on material and finish
|
const getFilteredColors = (
|
||||||
const getFilteredColors = (colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, type?: string, finish?: string) => {
|
colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
|
||||||
// PPA CF only has black color
|
type?: string,
|
||||||
if (type === 'PPA' && finish === 'CF') {
|
finish?: string
|
||||||
return colors.filter(color => color.name.toLowerCase() === 'black');
|
) => {
|
||||||
}
|
if (!type || !finish) return colors;
|
||||||
return colors;
|
const catalogColors = getColorsForMaterialFinish(type, finish);
|
||||||
|
if (catalogColors.length === 0) return colors;
|
||||||
|
const catalogNames = new Set(catalogColors.map(c => c.name.toLowerCase()));
|
||||||
|
return colors.filter(c => catalogNames.has(c.name.toLowerCase()));
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FilamentWithId extends Filament {
|
interface FilamentWithId extends Filament {
|
||||||
@@ -80,22 +50,6 @@ interface FilamentWithId extends Filament {
|
|||||||
boja_hex?: string;
|
boja_hex?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finish options by filament type
|
|
||||||
const FINISH_OPTIONS_BY_TYPE: Record<string, string[]> = {
|
|
||||||
'ABS': ['GF', 'Bez Finisha'],
|
|
||||||
'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', 'Tough+', 'Translucent', 'Wood'],
|
|
||||||
'TPU': ['85A', '90A', '95A HF'],
|
|
||||||
'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'],
|
|
||||||
'PC': ['CF', 'FR', 'Bez Finisha'],
|
|
||||||
'ASA': ['Bez Finisha'],
|
|
||||||
'PA': ['CF', 'GF', 'Bez Finisha'],
|
|
||||||
'PA6': ['CF', 'GF'],
|
|
||||||
'PAHT': ['CF', 'Bez Finisha'],
|
|
||||||
'PPA': ['CF'],
|
|
||||||
'PVA': ['Bez Finisha'],
|
|
||||||
'HIPS': ['Bez Finisha']
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
||||||
@@ -360,8 +314,10 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
{/* Main Content - Full Screen */}
|
<div className="flex">
|
||||||
<div className="w-full">
|
<AdminSidebar />
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1">
|
||||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 lg:py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 lg:py-6">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
@@ -401,24 +357,6 @@ export default function AdminDashboard() {
|
|||||||
filaments={filaments}
|
filaments={filaments}
|
||||||
onUpdate={fetchFilaments}
|
onUpdate={fetchFilaments}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => router.push('/upadaj/colors')}
|
|
||||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm sm:text-base"
|
|
||||||
>
|
|
||||||
Boje
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/upadaj/requests')}
|
|
||||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600 text-sm sm:text-base"
|
|
||||||
>
|
|
||||||
Zahtevi
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/')}
|
|
||||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm sm:text-base"
|
|
||||||
>
|
|
||||||
Nazad na sajt
|
|
||||||
</button>
|
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDarkMode(!darkMode)}
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
@@ -686,6 +624,7 @@ export default function AdminDashboard() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -710,7 +649,7 @@ function FilamentForm({
|
|||||||
finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments
|
finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments
|
||||||
boja: filament.boja || '',
|
boja: filament.boja || '',
|
||||||
boja_hex: filament.boja_hex || '',
|
boja_hex: filament.boja_hex || '',
|
||||||
refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0), // Store as number
|
refill: isSpoolOnly(filament.finish, filament.tip, filament.boja) ? 0 : (filament.refill || 0), // Store as number
|
||||||
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0), // Store as number
|
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0), // Store as number
|
||||||
kolicina: filament.kolicina || 0, // Default to 0, stored as number
|
kolicina: filament.kolicina || 0, // Default to 0, stored as number
|
||||||
cena: '', // Price is now determined by color selection
|
cena: '', // Price is now determined by color selection
|
||||||
@@ -787,9 +726,9 @@ function FilamentForm({
|
|||||||
});
|
});
|
||||||
} else if (name === 'tip') {
|
} else if (name === 'tip') {
|
||||||
// If changing filament type, reset finish if it's not compatible
|
// If changing filament type, reset finish if it's not compatible
|
||||||
const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || [];
|
const newTypeFinishes = getFinishesForMaterial(value);
|
||||||
const resetFinish = !newTypeFinishes.includes(formData.finish);
|
const resetFinish = !newTypeFinishes.includes(formData.finish);
|
||||||
const spoolOnly = isSpoolOnly(formData.finish, value);
|
const spoolOnly = isSpoolOnly(formData.finish, value, formData.boja);
|
||||||
// If changing to PPA with CF finish and current color is not black, reset color
|
// If changing to PPA with CF finish and current color is not black, reset color
|
||||||
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
|
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -810,7 +749,7 @@ function FilamentForm({
|
|||||||
} else if (name === 'finish') {
|
} else if (name === 'finish') {
|
||||||
// If changing to Translucent finish, enable spool option and disable refill
|
// If changing to Translucent finish, enable spool option and disable refill
|
||||||
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
|
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
|
||||||
const spoolOnly = isSpoolOnly(value, formData.tip);
|
const spoolOnly = isSpoolOnly(value, formData.tip, formData.boja);
|
||||||
// If changing to PPA CF and current color is not black, reset color
|
// If changing to PPA CF and current color is not black, reset color
|
||||||
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
|
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -887,17 +826,9 @@ function FilamentForm({
|
|||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Izaberi tip</option>
|
<option value="">Izaberi tip</option>
|
||||||
<option value="ABS">ABS</option>
|
{getMaterialOptions().map(mat => (
|
||||||
<option value="ASA">ASA</option>
|
<option key={mat} value={mat}>{mat}</option>
|
||||||
<option value="PA6">PA6</option>
|
))}
|
||||||
<option value="PAHT">PAHT</option>
|
|
||||||
<option value="PC">PC</option>
|
|
||||||
<option value="PET">PET</option>
|
|
||||||
<option value="PETG">PETG</option>
|
|
||||||
<option value="PLA">PLA</option>
|
|
||||||
<option value="PPA">PPA</option>
|
|
||||||
<option value="PPS">PPS</option>
|
|
||||||
<option value="TPU">TPU</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -911,7 +842,7 @@ function FilamentForm({
|
|||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Izaberi finiš</option>
|
<option value="">Izaberi finiš</option>
|
||||||
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
|
{getFinishesForMaterial(formData.tip).map(finish => (
|
||||||
<option key={finish} value={finish}>{finish}</option>
|
<option key={finish} value={finish}>{finish}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -991,9 +922,9 @@ function FilamentForm({
|
|||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="3499"
|
placeholder="3499"
|
||||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
disabled={isSpoolOnly(formData.finish, formData.tip, formData.boja)}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||||
isSpoolOnly(formData.finish, formData.tip)
|
isSpoolOnly(formData.finish, formData.tip, formData.boja)
|
||||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
||||||
: 'bg-white dark:bg-gray-700'
|
: 'bg-white dark:bg-gray-700'
|
||||||
} text-green-600 dark:text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
|
} text-green-600 dark:text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
|
||||||
@@ -1025,7 +956,7 @@ function FilamentForm({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
Refil
|
Refil
|
||||||
{isSpoolOnly(formData.finish, formData.tip) && (
|
{isSpoolOnly(formData.finish, formData.tip, formData.boja) && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
@@ -1037,9 +968,9 @@ function FilamentForm({
|
|||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
disabled={isSpoolOnly(formData.finish, formData.tip, formData.boja)}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||||
isSpoolOnly(formData.finish, formData.tip)
|
isSpoolOnly(formData.finish, formData.tip, formData.boja)
|
||||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
||||||
: 'bg-white dark:bg-gray-700'
|
: 'bg-white dark:bg-gray-700'
|
||||||
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
|
|||||||
34
app/page.tsx
34
app/page.tsx
@@ -91,6 +91,40 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
|
{/* Full-screen notice overlay - remove after March 8 */}
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 px-6 overflow-hidden">
|
||||||
|
{/* Subtle background glow */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-amber-500/5 rounded-full blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative max-w-xl w-full text-center space-y-10">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Filamenteka"
|
||||||
|
className="h-32 sm:h-44 w-auto mx-auto drop-shadow-2xl"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full">
|
||||||
|
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
|
||||||
|
<span className="text-amber-400 text-sm font-medium tracking-wide uppercase">Obaveštenje</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
||||||
|
Privremena pauza
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg sm:text-xl text-gray-400 leading-relaxed max-w-md mx-auto">
|
||||||
|
Trenutno primamo porudžbine samo od postojećih kupaca. Redovna prodaja se vraća nakon <span className="text-amber-400 font-semibold">8. marta</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-white/5">
|
||||||
|
<p className="text-gray-600 text-sm">Hvala na razumevanju</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
|
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
|
||||||
|
|||||||
427
app/upadaj/analytics/page.tsx
Normal file
427
app/upadaj/analytics/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { analyticsService } from '@/src/services/api';
|
||||||
|
import type { AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
|
type Period = '7d' | '30d' | '90d' | '6m' | '1y' | 'all';
|
||||||
|
|
||||||
|
const PERIOD_LABELS: Record<Period, string> = {
|
||||||
|
'7d': '7d',
|
||||||
|
'30d': '30d',
|
||||||
|
'90d': '90d',
|
||||||
|
'6m': '6m',
|
||||||
|
'1y': '1y',
|
||||||
|
'all': 'Sve',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERIOD_GROUP: Record<Period, string> = {
|
||||||
|
'7d': 'day',
|
||||||
|
'30d': 'day',
|
||||||
|
'90d': 'week',
|
||||||
|
'6m': 'month',
|
||||||
|
'1y': 'month',
|
||||||
|
'all': 'month',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRSD(value: number): string {
|
||||||
|
return new Intl.NumberFormat('sr-RS', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RSD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
const [period, setPeriod] = useState<Period>('30d');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||||
|
const [topSellers, setTopSellers] = useState<TopSeller[]>([]);
|
||||||
|
const [revenueData, setRevenueData] = useState<RevenueDataPoint[]>([]);
|
||||||
|
const [inventoryAlerts, setInventoryAlerts] = useState<InventoryAlert[]>([]);
|
||||||
|
const [typeBreakdown, setTypeBreakdown] = useState<TypeBreakdown[]>([]);
|
||||||
|
|
||||||
|
// Initialize dark mode
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
setDarkMode(saved !== null ? JSON.parse(saved) : true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
|
if (darkMode) document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
|
window.location.href = '/upadaj';
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const [overviewData, topSellersData, revenueChartData, alertsData, breakdownData] = await Promise.all([
|
||||||
|
analyticsService.getOverview(period),
|
||||||
|
analyticsService.getTopSellers(period),
|
||||||
|
analyticsService.getRevenueChart(period, PERIOD_GROUP[period]),
|
||||||
|
analyticsService.getInventoryAlerts(),
|
||||||
|
analyticsService.getTypeBreakdown(period),
|
||||||
|
]);
|
||||||
|
setOverview(overviewData);
|
||||||
|
setTopSellers(topSellersData);
|
||||||
|
setRevenueData(revenueChartData);
|
||||||
|
setInventoryAlerts(alertsData);
|
||||||
|
setTypeBreakdown(breakdownData);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Greska pri ucitavanju analitike');
|
||||||
|
console.error('Analytics fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('tokenExpiry');
|
||||||
|
router.push('/upadaj');
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIE_COLORS = ['#22c55e', '#3b82f6'];
|
||||||
|
|
||||||
|
const getStockRowClass = (alert: InventoryAlert): string => {
|
||||||
|
if (alert.days_until_stockout === null) return '';
|
||||||
|
if (alert.days_until_stockout < 7) return 'bg-red-50 dark:bg-red-900/20';
|
||||||
|
if (alert.days_until_stockout < 14) return 'bg-yellow-50 dark:bg-yellow-900/20';
|
||||||
|
return 'bg-green-50 dark:bg-green-900/20';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Filamenteka"
|
||||||
|
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Analitika</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Nazad na sajt
|
||||||
|
</button>
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||||
|
>
|
||||||
|
{darkMode ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Odjava
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="mb-6 flex gap-2">
|
||||||
|
{(Object.keys(PERIOD_LABELS) as Period[]).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-4 py-2 rounded font-medium transition-colors ${
|
||||||
|
period === p
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 shadow'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{PERIOD_LABELS[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje analitike...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prihod</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? formatRSD(overview.revenue) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Broj prodaja</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? overview.sales_count : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prosecna vrednost</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? formatRSD(overview.avg_order_value) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Jedinstveni kupci</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? overview.unique_customers : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue Chart */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Prihod po periodu</h2>
|
||||||
|
<div className="h-80">
|
||||||
|
{mounted && revenueData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={revenueData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="period"
|
||||||
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||||
|
tickFormatter={(value) => formatRSD(value)}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={((value: number) => [formatRSD(value), 'Prihod']) as any}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: darkMode ? '#f3f4f6' : '#111827',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#3b82f6', r: 4 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
Nema podataka za prikaz
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
{/* Top Sellers */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Najprodavanije boje</h2>
|
||||||
|
<div className="h-80">
|
||||||
|
{mounted && topSellers.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={topSellers} layout="vertical" margin={{ left: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
||||||
|
<XAxis type="number" stroke={darkMode ? '#9ca3af' : '#6b7280'} tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="boja"
|
||||||
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
width={120}
|
||||||
|
tickFormatter={(value: string, index: number) => {
|
||||||
|
const seller = topSellers[index];
|
||||||
|
return seller ? `${value} (${seller.tip})` : value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={((value: number) => [value, 'Kolicina']) as any}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: darkMode ? '#f3f4f6' : '#111827',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total_qty" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
Nema podataka za prikaz
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Breakdown */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Refill vs Spulna</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
{mounted && typeBreakdown.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={typeBreakdown}
|
||||||
|
dataKey="total_qty"
|
||||||
|
nameKey="item_type"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
label={({ name, percent }: any) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||||
|
>
|
||||||
|
{typeBreakdown.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={((value: number, name: string) => [
|
||||||
|
`${value} kom | ${formatRSD(typeBreakdown.find(t => t.item_type === name)?.total_revenue ?? 0)}`,
|
||||||
|
name,
|
||||||
|
]) as any}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: darkMode ? '#f3f4f6' : '#111827',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
Nema podataka za prikaz
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory Alerts */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upozorenja za zalihe</h2>
|
||||||
|
{inventoryAlerts.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Boja</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tip</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Finish</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Refill</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Spulna</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Kolicina</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pros. dnevna prodaja</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dana do nestanka</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{inventoryAlerts.map((alert) => (
|
||||||
|
<tr key={alert.id} className={getStockRowClass(alert)}>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.boja}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.tip}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.finish}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.refill}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.spulna}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right font-medium">{alert.kolicina}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">
|
||||||
|
{alert.avg_daily_sales > 0 ? alert.avg_daily_sales.toFixed(1) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right font-medium">
|
||||||
|
{alert.days_until_stockout !== null ? (
|
||||||
|
<span className={
|
||||||
|
alert.days_until_stockout < 7
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: alert.days_until_stockout < 14
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-green-600 dark:text-green-400'
|
||||||
|
}>
|
||||||
|
{Math.round(alert.days_until_stockout)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Sve zalihe su na zadovoljavajucem nivou.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { colorService } from '@/src/services/api';
|
import { colorService } from '@/src/services/api';
|
||||||
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
||||||
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
|
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
|
||||||
interface Color {
|
interface Color {
|
||||||
@@ -188,26 +189,7 @@ export default function ColorsManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Sidebar */}
|
<AdminSidebar />
|
||||||
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen">
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
|
||||||
<nav className="space-y-2">
|
|
||||||
<a
|
|
||||||
href="/dashboard"
|
|
||||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
Filamenti
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/upadaj/colors"
|
|
||||||
className="block px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded"
|
|
||||||
>
|
|
||||||
Boje
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
493
app/upadaj/customers/page.tsx
Normal file
493
app/upadaj/customers/page.tsx
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { customerService } from '@/src/services/api';
|
||||||
|
import { Customer, Sale } from '@/src/types/sales';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
|
interface CustomerWithSales extends Customer {
|
||||||
|
sales?: Sale[];
|
||||||
|
total_purchases?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomersManagement() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [customers, setCustomers] = useState<CustomerWithSales[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [expandedCustomerId, setExpandedCustomerId] = useState<string | null>(null);
|
||||||
|
const [expandedSales, setExpandedSales] = useState<Sale[]>([]);
|
||||||
|
const [loadingSales, setLoadingSales] = useState(false);
|
||||||
|
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<Partial<Customer>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Initialize dark mode
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
if (saved !== null) {
|
||||||
|
setDarkMode(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
setDarkMode(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
|
if (darkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
|
window.location.href = '/upadaj';
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
// Fetch customers
|
||||||
|
const fetchCustomers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await customerService.getAll();
|
||||||
|
setCustomers(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Greska pri ucitavanju kupaca');
|
||||||
|
console.error('Fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
}, [fetchCustomers]);
|
||||||
|
|
||||||
|
// Search customers
|
||||||
|
const filteredCustomers = customers.filter((customer) => {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
customer.name.toLowerCase().includes(term) ||
|
||||||
|
(customer.phone && customer.phone.toLowerCase().includes(term)) ||
|
||||||
|
(customer.city && customer.city.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle expanded row to show purchase history
|
||||||
|
const handleToggleExpand = async (customerId: string) => {
|
||||||
|
if (expandedCustomerId === customerId) {
|
||||||
|
setExpandedCustomerId(null);
|
||||||
|
setExpandedSales([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedCustomerId(customerId);
|
||||||
|
setLoadingSales(true);
|
||||||
|
try {
|
||||||
|
const data = await customerService.getById(customerId);
|
||||||
|
setExpandedSales(data.sales || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching customer sales:', err);
|
||||||
|
setExpandedSales([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingSales(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit customer
|
||||||
|
const handleStartEdit = (customer: Customer) => {
|
||||||
|
setEditingCustomer(customer);
|
||||||
|
setEditForm({
|
||||||
|
name: customer.name,
|
||||||
|
phone: customer.phone || '',
|
||||||
|
city: customer.city || '',
|
||||||
|
notes: customer.notes || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingCustomer(null);
|
||||||
|
setEditForm({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editingCustomer) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await customerService.update(editingCustomer.id, editForm);
|
||||||
|
setEditingCustomer(null);
|
||||||
|
setEditForm({});
|
||||||
|
await fetchCustomers();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Greska pri cuvanju izmena');
|
||||||
|
console.error('Save error:', err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('tokenExpiry');
|
||||||
|
router.push('/upadaj');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString('sr-Latn-RS', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('sr-Latn-RS', {
|
||||||
|
style: 'decimal',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount) + ' RSD';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Filamenteka"
|
||||||
|
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Upravljanje kupcima
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Nazad na sajt
|
||||||
|
</button>
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||||
|
>
|
||||||
|
{darkMode ? '\u2600' : '\u263D'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Odjava
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||||
|
{error}
|
||||||
|
<button
|
||||||
|
onClick={() => setError('')}
|
||||||
|
className="ml-4 text-red-600 dark:text-red-300 underline text-sm"
|
||||||
|
>
|
||||||
|
Zatvori
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Pretrazi kupce..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full max-w-md px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer count */}
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Ukupno kupaca: {filteredCustomers.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Customer table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Ime
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Telefon
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Grad
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Beleske
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Datum registracije
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Akcije
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{filteredCustomers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-6 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{searchTerm ? 'Nema rezultata pretrage' : 'Nema registrovanih kupaca'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredCustomers.map((customer) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={customer.id}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
{editingCustomer?.id === customer.id ? (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, name: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.phone || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, phone: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.city || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, city: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<textarea
|
||||||
|
value={editForm.notes || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, notes: e.target.value })
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
placeholder="npr. stampa figurice, obicno crna..."
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDate(customer.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? '...' : 'Sacuvaj'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="px-3 py-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm rounded hover:bg-gray-400 dark:hover:bg-gray-500"
|
||||||
|
>
|
||||||
|
Otkazi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{customer.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{customer.phone || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{customer.city || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs whitespace-pre-wrap">
|
||||||
|
{customer.notes || <span className="text-gray-400 dark:text-gray-600 italic">Nema beleski</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDate(customer.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEdit(customer)}
|
||||||
|
className="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Izmeni
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleExpand(customer.id)}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${
|
||||||
|
expandedCustomerId === customer.id
|
||||||
|
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||||
|
: 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Istorija kupovina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded purchase history */}
|
||||||
|
{expandedCustomerId === customer.id && (
|
||||||
|
<tr key={`${customer.id}-sales`}>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50"
|
||||||
|
>
|
||||||
|
<div className="ml-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Istorija kupovina - {customer.name}
|
||||||
|
</h4>
|
||||||
|
{loadingSales ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Ucitavanje...
|
||||||
|
</p>
|
||||||
|
) : expandedSales.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Nema evidentiranih kupovina
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded overflow-hidden">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Stavke
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Iznos
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Napomena
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{expandedSales.map((sale) => (
|
||||||
|
<tr key={sale.id}>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{sale.id.substring(0, 8)}...
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(sale.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{sale.item_count ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white font-medium">
|
||||||
|
{formatCurrency(sale.total_amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{sale.notes || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-right"
|
||||||
|
>
|
||||||
|
Ukupno ({expandedSales.length} kupovina):
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatCurrency(
|
||||||
|
expandedSales.reduce(
|
||||||
|
(sum, sale) => sum + sale.total_amount,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,15 @@ export default function AdminLogin() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Redirect to dashboard if already authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (token && expiry && Date.now() < parseInt(expiry)) {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Set dark mode by default
|
// Set dark mode by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { colorRequestService } from '@/src/services/api';
|
import { colorRequestService } from '@/src/services/api';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
interface ColorRequest {
|
interface ColorRequest {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -120,23 +121,11 @@ export default function ColorRequestsAdmin() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
<div className="flex-1 p-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
|
||||||
<div className="space-x-4">
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Inventar
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/upadaj/colors"
|
|
||||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Boje
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -354,6 +343,7 @@ export default function ColorRequestsAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
827
app/upadaj/sales/page.tsx
Normal file
827
app/upadaj/sales/page.tsx
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { saleService, customerService, filamentService, colorService } from '@/src/services/api';
|
||||||
|
import { Customer, Sale, SaleItem, CreateSaleRequest } from '@/src/types/sales';
|
||||||
|
import { Filament } from '@/src/types/filament';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
|
interface LineItem {
|
||||||
|
filament_id: string;
|
||||||
|
item_type: 'refill' | 'spulna';
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
|
||||||
|
// Sales list state
|
||||||
|
const [sales, setSales] = useState<Sale[]>([]);
|
||||||
|
const [totalSales, setTotalSales] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [showNewSaleModal, setShowNewSaleModal] = useState(false);
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [selectedSale, setSelectedSale] = useState<Sale | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
// Dark mode init
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
setDarkMode(saved !== null ? JSON.parse(saved) : true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
|
if (darkMode) document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// Auth check
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
|
window.location.href = '/upadaj';
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
// Fetch sales
|
||||||
|
const fetchSales = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const data = await saleService.getAll(page, LIMIT);
|
||||||
|
setSales(data.sales);
|
||||||
|
setTotalSales(data.total);
|
||||||
|
} catch {
|
||||||
|
setError('Greska pri ucitavanju prodaja');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
fetchSales();
|
||||||
|
}, [mounted, fetchSales]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalSales / LIMIT);
|
||||||
|
|
||||||
|
const handleViewDetail = async (sale: Sale) => {
|
||||||
|
try {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
const detail = await saleService.getById(sale.id);
|
||||||
|
setSelectedSale(detail);
|
||||||
|
} catch {
|
||||||
|
setError('Greska pri ucitavanju detalja prodaje');
|
||||||
|
setShowDetailModal(false);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSale = async (id: string) => {
|
||||||
|
if (!window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) return;
|
||||||
|
try {
|
||||||
|
await saleService.delete(id);
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedSale(null);
|
||||||
|
fetchSales();
|
||||||
|
} catch {
|
||||||
|
setError('Greska pri brisanju prodaje');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('sr-RS', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (amount: number) => {
|
||||||
|
return amount.toLocaleString('sr-RS') + ' RSD';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 p-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Prodaja</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{darkMode ? 'Svetli mod' : 'Tamni mod'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewSaleModal(true)}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Nova prodaja
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sales Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
) : sales.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Nema prodaja</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Ime kupca
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Stavki
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Cena
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Akcije
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{sales.map((sale) => (
|
||||||
|
<tr key={sale.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDate(sale.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{sale.customer_name || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{sale.item_count ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{formatPrice(sale.total_amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewDetail(sale)}
|
||||||
|
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Detalji
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSale(sale.id)}
|
||||||
|
className="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Obrisi
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex justify-center items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Prethodna
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Strana {page} od {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Sledeca
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Sale Modal */}
|
||||||
|
{showNewSaleModal && (
|
||||||
|
<NewSaleModal
|
||||||
|
onClose={() => setShowNewSaleModal(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowNewSaleModal(false);
|
||||||
|
fetchSales();
|
||||||
|
}}
|
||||||
|
formatPrice={formatPrice}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sale Detail Modal */}
|
||||||
|
{showDetailModal && (
|
||||||
|
<SaleDetailModal
|
||||||
|
sale={selectedSale}
|
||||||
|
loading={detailLoading}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedSale(null);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => handleDeleteSale(id)}
|
||||||
|
formatPrice={formatPrice}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NewSaleModal ---
|
||||||
|
|
||||||
|
function NewSaleModal({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
formatPrice,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
formatPrice: (n: number) => string;
|
||||||
|
}) {
|
||||||
|
const [customerName, setCustomerName] = useState('');
|
||||||
|
const [customerPhone, setCustomerPhone] = useState('');
|
||||||
|
const [customerCity, setCustomerCity] = useState('');
|
||||||
|
const [customerNotes, setCustomerNotes] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [items, setItems] = useState<LineItem[]>([{ filament_id: '', item_type: 'refill', quantity: 1 }]);
|
||||||
|
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||||
|
const [colorPrices, setColorPrices] = useState<Record<string, { cena_refill: number; cena_spulna: number }>>({});
|
||||||
|
const [customerSuggestions, setCustomerSuggestions] = useState<Customer[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [filamentData, colorData] = await Promise.all([
|
||||||
|
filamentService.getAll(),
|
||||||
|
colorService.getAll(),
|
||||||
|
]);
|
||||||
|
setFilaments(filamentData);
|
||||||
|
const priceMap: Record<string, { cena_refill: number; cena_spulna: number }> = {};
|
||||||
|
for (const c of colorData) {
|
||||||
|
priceMap[c.name] = { cena_refill: c.cena_refill || 3499, cena_spulna: c.cena_spulna || 3999 };
|
||||||
|
}
|
||||||
|
setColorPrices(priceMap);
|
||||||
|
} catch {
|
||||||
|
// Data failed to load
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close suggestions on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCustomerSearch = (value: string) => {
|
||||||
|
setCustomerName(value);
|
||||||
|
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||||
|
if (value.length < 2) {
|
||||||
|
setCustomerSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const results = await customerService.search(value);
|
||||||
|
setCustomerSuggestions(results);
|
||||||
|
setShowSuggestions(results.length > 0);
|
||||||
|
} catch {
|
||||||
|
setCustomerSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectCustomer = (customer: Customer) => {
|
||||||
|
setCustomerName(customer.name);
|
||||||
|
setCustomerPhone(customer.phone || '');
|
||||||
|
setCustomerCity(customer.city || '');
|
||||||
|
setCustomerNotes(customer.notes || '');
|
||||||
|
setShowSuggestions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilamentById = (id: string): Filament | undefined => {
|
||||||
|
return filaments.find((f) => String(f.id) === String(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilamentPrice = (filament: Filament, type: 'refill' | 'spulna'): number => {
|
||||||
|
const prices = colorPrices[filament.boja];
|
||||||
|
const base = type === 'refill' ? (prices?.cena_refill || 3499) : (prices?.cena_spulna || 3999);
|
||||||
|
if (filament.sale_active && filament.sale_percentage) {
|
||||||
|
return Math.round(base * (1 - filament.sale_percentage / 100));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemPrice = (item: LineItem): number => {
|
||||||
|
const filament = getFilamentById(item.filament_id);
|
||||||
|
if (!filament) return 0;
|
||||||
|
return getFilamentPrice(filament, item.item_type) * item.quantity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + getItemPrice(item), 0);
|
||||||
|
|
||||||
|
const getAvailableStock = (filament: Filament, type: 'refill' | 'spulna'): number => {
|
||||||
|
return type === 'refill' ? filament.refill : filament.spulna;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (index: number, updates: Partial<LineItem>) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = { ...next[index], ...updates };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
if (items.length <= 1) return;
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setItems((prev) => [...prev, { filament_id: '', item_type: 'refill', quantity: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!customerName.trim()) {
|
||||||
|
setSubmitError('Ime kupca je obavezno');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validItems = items.filter((item) => item.filament_id !== '' && item.quantity > 0);
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
setSubmitError('Dodajte bar jednu stavku');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate stock
|
||||||
|
for (const item of validItems) {
|
||||||
|
const filament = getFilamentById(item.filament_id);
|
||||||
|
if (!filament) {
|
||||||
|
setSubmitError('Nevalidan filament');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const available = getAvailableStock(filament, item.item_type);
|
||||||
|
if (item.quantity > available) {
|
||||||
|
setSubmitError(
|
||||||
|
`Nedovoljno zaliha za ${filament.boja} ${filament.tip} (${item.item_type}): dostupno ${available}, trazeno ${item.quantity}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: CreateSaleRequest = {
|
||||||
|
customer: {
|
||||||
|
name: customerName.trim(),
|
||||||
|
phone: customerPhone.trim() || undefined,
|
||||||
|
city: customerCity.trim() || undefined,
|
||||||
|
notes: customerNotes.trim() || undefined,
|
||||||
|
},
|
||||||
|
items: validItems.map((item) => ({
|
||||||
|
filament_id: item.filament_id,
|
||||||
|
item_type: item.item_type,
|
||||||
|
quantity: item.quantity,
|
||||||
|
})),
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
await saleService.create(payload);
|
||||||
|
onCreated();
|
||||||
|
} catch {
|
||||||
|
setSubmitError('Greska pri kreiranju prodaje');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Nova prodaja</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded text-sm">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Customer Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="relative" ref={suggestionsRef}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Ime kupca *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => handleCustomerSearch(e.target.value)}
|
||||||
|
onFocus={() => customerSuggestions.length > 0 && setShowSuggestions(true)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Ime i prezime"
|
||||||
|
/>
|
||||||
|
{showSuggestions && customerSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-40 overflow-y-auto">
|
||||||
|
{customerSuggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => selectCustomer(c)}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.city && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-2">({c.city})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Telefon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerPhone}
|
||||||
|
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Broj telefona"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Grad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerCity}
|
||||||
|
onChange={(e) => setCustomerCity(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Grad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beleske o kupcu</div>
|
||||||
|
<textarea
|
||||||
|
value={customerNotes}
|
||||||
|
onChange={(e) => setCustomerNotes(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="npr. stampa figurice, obicno koristi crnu..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Stavke</h3>
|
||||||
|
<button
|
||||||
|
onClick={addItem}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Dodaj stavku
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const filament = getFilamentById(item.filament_id);
|
||||||
|
const available = filament ? getAvailableStock(filament, item.item_type) : 0;
|
||||||
|
const itemPrice = getItemPrice(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-wrap gap-3 items-end p-3 bg-gray-50 dark:bg-gray-700/50 rounded border border-gray-200 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Filament
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={item.filament_id}
|
||||||
|
onChange={(e) => updateItem(index, { filament_id: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value={0}>-- Izaberite filament --</option>
|
||||||
|
{filaments
|
||||||
|
.filter((f) => f.kolicina > 0)
|
||||||
|
.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.boja} - {f.tip} {f.finish} (refill: {f.refill}, spulna: {f.spulna})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Tip
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={item.item_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(index, {
|
||||||
|
item_type: e.target.value as 'refill' | 'spulna',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="refill">Refill</option>
|
||||||
|
<option value="spulna">Spulna</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Kolicina {filament ? `(max ${available})` : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={available || undefined}
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, { quantity: Math.max(1, Number(e.target.value)) })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 text-right">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Cena
|
||||||
|
</label>
|
||||||
|
<div className="px-3 py-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{itemPrice > 0 ? formatPrice(itemPrice) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
disabled={items.length <= 1}
|
||||||
|
className="px-3 py-2 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Ukloni
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
|
||||||
|
<span className="text-xl font-bold text-gray-900 dark:text-white">{formatPrice(totalAmount)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Beleske
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Napomena za prodaju..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
Otkazi
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{submitting ? 'Cuvanje...' : 'Sacuvaj'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SaleDetailModal ---
|
||||||
|
|
||||||
|
function SaleDetailModal({
|
||||||
|
sale,
|
||||||
|
loading,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
formatPrice,
|
||||||
|
formatDate,
|
||||||
|
}: {
|
||||||
|
sale: Sale | null;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
formatPrice: (n: number) => string;
|
||||||
|
formatDate: (d?: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Detalji prodaje</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
) : !sale ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Prodaja nije pronadjena</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Customer Info */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Ime: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{sale.customer_name || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Telefon: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{sale.customer_phone || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Datum: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{formatDate(sale.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Stavke</h3>
|
||||||
|
{sale.items && sale.items.length > 0 ? (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-600 rounded overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Filament
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Tip
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Kolicina
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Cena
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-600">
|
||||||
|
{sale.items.map((item: SaleItem) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white">
|
||||||
|
{item.filament_boja} - {item.filament_tip} {item.filament_finish}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 capitalize">
|
||||||
|
{item.item_type}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right">
|
||||||
|
{item.quantity}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right font-medium">
|
||||||
|
{formatPrice(item.unit_price * item.quantity)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Nema stavki</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
|
||||||
|
<span className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatPrice(sale.total_amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{sale.notes && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Beleske</h3>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-gray-700/50 p-3 rounded">
|
||||||
|
{sale.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) {
|
||||||
|
onDelete(sale.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Obrisi prodaju
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
Zatvori
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
database/migrations/021_add_customers_table.sql
Normal file
16
database/migrations/021_add_customers_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Add customers table for tracking buyers
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
city VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phone is the natural dedup key
|
||||||
|
CREATE UNIQUE INDEX idx_customers_phone ON customers (phone) WHERE phone IS NOT NULL;
|
||||||
|
CREATE INDEX idx_customers_name ON customers (name);
|
||||||
24
database/migrations/022_add_sales_tables.sql
Normal file
24
database/migrations/022_add_sales_tables.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Add sales and sale_items tables for tracking transactions
|
||||||
|
CREATE TABLE sales (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
total_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sale_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sale_id UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
|
||||||
|
filament_id UUID NOT NULL REFERENCES filaments(id) ON DELETE RESTRICT,
|
||||||
|
item_type VARCHAR(10) NOT NULL CHECK (item_type IN ('refill', 'spulna')),
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
unit_price INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sales_customer_id ON sales (customer_id);
|
||||||
|
CREATE INDEX idx_sales_created_at ON sales (created_at);
|
||||||
|
CREATE INDEX idx_sale_items_sale_id ON sale_items (sale_id);
|
||||||
|
CREATE INDEX idx_sale_items_filament_id ON sale_items (filament_id);
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
-- Colors table
|
-- Colors table
|
||||||
CREATE TABLE colors (
|
CREATE TABLE IF NOT EXISTS colors (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
hex VARCHAR(7) NOT NULL,
|
hex VARCHAR(7) NOT NULL,
|
||||||
@@ -15,7 +15,7 @@ CREATE TABLE colors (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Filaments table
|
-- Filaments table
|
||||||
CREATE TABLE filaments (
|
CREATE TABLE IF NOT EXISTS filaments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
tip VARCHAR(50) NOT NULL,
|
tip VARCHAR(50) NOT NULL,
|
||||||
finish VARCHAR(50) NOT NULL,
|
finish VARCHAR(50) NOT NULL,
|
||||||
@@ -32,9 +32,9 @@ CREATE TABLE filaments (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Create indexes for better performance
|
-- Create indexes for better performance
|
||||||
CREATE INDEX idx_filaments_tip ON filaments(tip);
|
CREATE INDEX IF NOT EXISTS idx_filaments_tip ON filaments(tip);
|
||||||
CREATE INDEX idx_filaments_boja ON filaments(boja);
|
CREATE INDEX IF NOT EXISTS idx_filaments_boja ON filaments(boja);
|
||||||
CREATE INDEX idx_filaments_created_at ON filaments(created_at);
|
CREATE INDEX IF NOT EXISTS idx_filaments_created_at ON filaments(created_at);
|
||||||
|
|
||||||
-- Create updated_at trigger function
|
-- Create updated_at trigger function
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
@@ -46,10 +46,12 @@ END;
|
|||||||
$$ language 'plpgsql';
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
-- Apply trigger to filaments table
|
-- Apply trigger to filaments table
|
||||||
|
DROP TRIGGER IF EXISTS update_filaments_updated_at ON filaments;
|
||||||
CREATE TRIGGER update_filaments_updated_at BEFORE UPDATE
|
CREATE TRIGGER update_filaments_updated_at BEFORE UPDATE
|
||||||
ON filaments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
ON filaments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
-- Apply trigger to colors table
|
-- Apply trigger to colors table
|
||||||
|
DROP TRIGGER IF EXISTS update_colors_updated_at ON colors;
|
||||||
CREATE TRIGGER update_colors_updated_at BEFORE UPDATE
|
CREATE TRIGGER update_colors_updated_at BEFORE UPDATE
|
||||||
ON colors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
ON colors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
|||||||
407
package-lock.json
generated
407
package-lock.json
generated
@@ -15,7 +15,8 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -2672,6 +2673,42 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -3056,6 +3093,18 @@
|
|||||||
"@sinonjs/commons": "^3.0.1"
|
"@sinonjs/commons": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -3214,6 +3263,69 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3326,7 +3438,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -3363,6 +3475,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -4560,6 +4678,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -4715,9 +4842,130 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||||
@@ -4757,6 +5005,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
||||||
@@ -5059,6 +5313,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -5336,6 +5600,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -6012,6 +6282,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -6088,6 +6368,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
@@ -8569,10 +8858,32 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -8606,6 +8917,36 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -8620,6 +8961,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -8630,6 +8986,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -9423,6 +9785,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -9724,6 +10092,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -9746,6 +10123,28 @@
|
|||||||
"node": ">=10.12.0"
|
"node": ">=10.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
42
src/components/AdminSidebar.tsx
Normal file
42
src/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/dashboard', label: 'Filamenti' },
|
||||||
|
{ href: '/upadaj/colors', label: 'Boje' },
|
||||||
|
{ href: '/upadaj/requests', label: 'Zahtevi za boje' },
|
||||||
|
{ href: '/upadaj/sales', label: 'Prodaja' },
|
||||||
|
{ href: '/upadaj/customers', label: 'Kupci' },
|
||||||
|
{ href: '/upadaj/analytics', label: 'Analitika' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen sticky top-0 flex-shrink-0">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`block px-4 py-2 rounded ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
import { trackEvent } from './MatomoAnalytics';
|
import { trackEvent } from './MatomoAnalytics';
|
||||||
|
import { getFinishesForMaterial, getAllFinishes, getMaterialOptions } from '@/src/data/bambuLabCatalog';
|
||||||
|
import { Filament } from '@/src/types/filament';
|
||||||
|
|
||||||
interface EnhancedFiltersProps {
|
interface EnhancedFiltersProps {
|
||||||
filters: {
|
filters: {
|
||||||
@@ -8,22 +10,74 @@ interface EnhancedFiltersProps {
|
|||||||
finish: string;
|
finish: string;
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
onFilterChange: (filters: any) => void;
|
onFilterChange: (filters: { material: string; finish: string; color: string }) => void;
|
||||||
uniqueValues: {
|
uniqueValues: {
|
||||||
materials: string[];
|
materials: string[];
|
||||||
finishes: string[];
|
finishes: string[];
|
||||||
colors: string[];
|
colors: string[];
|
||||||
};
|
};
|
||||||
|
inventoryFilaments?: Filament[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||||
filters,
|
filters,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
uniqueValues
|
uniqueValues,
|
||||||
|
inventoryFilaments = []
|
||||||
}) => {
|
}) => {
|
||||||
// Check if any filters are active
|
|
||||||
const hasActiveFilters = filters.material || filters.finish || filters.color;
|
const hasActiveFilters = filters.material || filters.finish || filters.color;
|
||||||
|
|
||||||
|
// Catalog-aware material list (static from catalog)
|
||||||
|
const materialOptions = useMemo(() => getMaterialOptions(), []);
|
||||||
|
|
||||||
|
// Finish options: conditional on selected material
|
||||||
|
const finishOptions = useMemo(() => {
|
||||||
|
const inStock = inventoryFilaments.filter(f => f.kolicina > 0);
|
||||||
|
if (filters.material) {
|
||||||
|
// Show finishes from catalog for this material, but only if they exist in inventory
|
||||||
|
const catalogFinishes = getFinishesForMaterial(filters.material);
|
||||||
|
const inventoryFinishes = new Set(
|
||||||
|
inStock.filter(f => f.tip === filters.material).map(f => f.finish)
|
||||||
|
);
|
||||||
|
return catalogFinishes.filter(f => inventoryFinishes.has(f));
|
||||||
|
}
|
||||||
|
// No material selected: show all finishes from inventory
|
||||||
|
if (inStock.length > 0) {
|
||||||
|
return [...new Set(inStock.map(f => f.finish))].sort();
|
||||||
|
}
|
||||||
|
return getAllFinishes();
|
||||||
|
}, [filters.material, inventoryFilaments]);
|
||||||
|
|
||||||
|
// Color options: conditional on selected material + finish
|
||||||
|
const colorOptions = useMemo(() => {
|
||||||
|
const inStock = inventoryFilaments.filter(f => f.kolicina > 0);
|
||||||
|
let filtered = inStock;
|
||||||
|
if (filters.material) {
|
||||||
|
filtered = filtered.filter(f => f.tip === filters.material);
|
||||||
|
}
|
||||||
|
if (filters.finish) {
|
||||||
|
filtered = filtered.filter(f => f.finish === filters.finish);
|
||||||
|
}
|
||||||
|
return [...new Set(filtered.map(f => f.boja))].sort();
|
||||||
|
}, [filters.material, filters.finish, inventoryFilaments]);
|
||||||
|
|
||||||
|
const handleMaterialChange = (value: string) => {
|
||||||
|
// Reset finish and color when material changes
|
||||||
|
onFilterChange({ material: value, finish: '', color: '' });
|
||||||
|
trackEvent('Filter', 'Material', value || 'All');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinishChange = (value: string) => {
|
||||||
|
// Reset color when finish changes
|
||||||
|
onFilterChange({ ...filters, finish: value, color: '' });
|
||||||
|
trackEvent('Filter', 'Finish', value || 'All');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = (value: string) => {
|
||||||
|
onFilterChange({ ...filters, color: value });
|
||||||
|
trackEvent('Filter', 'Color', value || 'All');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
{/* Filters Grid */}
|
{/* Filters Grid */}
|
||||||
@@ -35,26 +89,15 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.material}
|
value={filters.material}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleMaterialChange(e.target.value)}
|
||||||
onFilterChange({ ...filters, material: e.target.value });
|
|
||||||
trackEvent('Filter', 'Material', e.target.value || 'All');
|
|
||||||
}}
|
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Svi materijali</option>
|
<option value="">Svi materijali</option>
|
||||||
<option value="ABS">ABS</option>
|
{materialOptions.map(mat => (
|
||||||
<option value="ASA">ASA</option>
|
<option key={mat} value={mat}>{mat}</option>
|
||||||
<option value="PA6">PA6</option>
|
))}
|
||||||
<option value="PAHT">PAHT</option>
|
|
||||||
<option value="PC">PC</option>
|
|
||||||
<option value="PET">PET</option>
|
|
||||||
<option value="PETG">PETG</option>
|
|
||||||
<option value="PLA">PLA</option>
|
|
||||||
<option value="PPA">PPA</option>
|
|
||||||
<option value="PPS">PPS</option>
|
|
||||||
<option value="TPU">TPU</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,35 +108,15 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.finish}
|
value={filters.finish}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleFinishChange(e.target.value)}
|
||||||
onFilterChange({ ...filters, finish: e.target.value });
|
|
||||||
trackEvent('Filter', 'Finish', e.target.value || 'All');
|
|
||||||
}}
|
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Svi finish tipovi</option>
|
<option value="">Svi finish tipovi</option>
|
||||||
<option value="85A">85A</option>
|
{finishOptions.map(finish => (
|
||||||
<option value="90A">90A</option>
|
<option key={finish} value={finish}>{finish}</option>
|
||||||
<option value="95A HF">95A HF</option>
|
))}
|
||||||
<option value="Aero">Aero</option>
|
|
||||||
<option value="Basic">Basic</option>
|
|
||||||
<option value="Basic Gradient">Basic Gradient</option>
|
|
||||||
<option value="CF">CF</option>
|
|
||||||
<option value="FR">FR</option>
|
|
||||||
<option value="Galaxy">Galaxy</option>
|
|
||||||
<option value="GF">GF</option>
|
|
||||||
<option value="Glow">Glow</option>
|
|
||||||
<option value="HF">HF</option>
|
|
||||||
<option value="Marble">Marble</option>
|
|
||||||
<option value="Matte">Matte</option>
|
|
||||||
<option value="Metal">Metal</option>
|
|
||||||
<option value="Silk Multi-Color">Silk Multi-Color</option>
|
|
||||||
<option value="Silk+">Silk+</option>
|
|
||||||
<option value="Sparkle">Sparkle</option>
|
|
||||||
<option value="Translucent">Translucent</option>
|
|
||||||
<option value="Wood">Wood</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,16 +127,13 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.color}
|
value={filters.color}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleColorChange(e.target.value)}
|
||||||
onFilterChange({ ...filters, color: e.target.value });
|
|
||||||
trackEvent('Filter', 'Color', e.target.value || 'All');
|
|
||||||
}}
|
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Sve boje</option>
|
<option value="">Sve boje</option>
|
||||||
{uniqueValues.colors.map(color => (
|
{colorOptions.map(color => (
|
||||||
<option key={color} value={color}>{color}</option>
|
<option key={color} value={color}>{color}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
onFilterChange={setFilters}
|
onFilterChange={setFilters}
|
||||||
uniqueValues={uniqueValues}
|
uniqueValues={uniqueValues}
|
||||||
|
inventoryFilaments={filaments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
407
src/data/bambuLabCatalog.ts
Normal file
407
src/data/bambuLabCatalog.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
// Master Bambu Lab product catalog — single source of truth
|
||||||
|
// Material → Finish → Color[] with refill/spool availability
|
||||||
|
|
||||||
|
export interface CatalogColorEntry {
|
||||||
|
name: string;
|
||||||
|
refill: boolean;
|
||||||
|
spool: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogFinish {
|
||||||
|
colors: CatalogColorEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BambuLabCatalog = Record<string, Record<string, CatalogFinish>>;
|
||||||
|
|
||||||
|
export const BAMBU_LAB_CATALOG: BambuLabCatalog = {
|
||||||
|
PLA: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Jade White', refill: true, spool: true },
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
{ name: 'Red', refill: true, spool: true },
|
||||||
|
{ name: 'Bambu Green', refill: true, spool: true },
|
||||||
|
{ name: 'Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Scarlet Red', refill: true, spool: true },
|
||||||
|
{ name: 'Lemon Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'Cyan', refill: true, spool: true },
|
||||||
|
{ name: 'Sakura Pink', refill: true, spool: true },
|
||||||
|
{ name: 'Cobalt Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Mistletoe Green', refill: true, spool: true },
|
||||||
|
{ name: 'Dark Red', refill: true, spool: true },
|
||||||
|
{ name: 'Hot Pink', refill: true, spool: true },
|
||||||
|
{ name: 'Lavender', refill: true, spool: true },
|
||||||
|
{ name: 'Light Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Sky Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Sunflower Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'Pumpkin Orange', refill: true, spool: true },
|
||||||
|
{ name: 'Lime', refill: true, spool: true },
|
||||||
|
{ name: 'Blue Grey', refill: true, spool: false },
|
||||||
|
{ name: 'Beige', refill: true, spool: false },
|
||||||
|
{ name: 'Light Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'Orange', refill: true, spool: false },
|
||||||
|
{ name: 'Gold', refill: true, spool: false },
|
||||||
|
{ name: 'Bright Green', refill: true, spool: false },
|
||||||
|
{ name: 'Pink', refill: true, spool: false },
|
||||||
|
{ name: 'Magenta', refill: true, spool: false },
|
||||||
|
{ name: 'Maroon Red', refill: true, spool: false },
|
||||||
|
{ name: 'Purple', refill: true, spool: false },
|
||||||
|
{ name: 'Turquoise', refill: true, spool: false },
|
||||||
|
{ name: 'Brown', refill: true, spool: false },
|
||||||
|
{ name: 'Bronze', refill: true, spool: false },
|
||||||
|
{ name: 'Silver', refill: true, spool: false },
|
||||||
|
{ name: 'Dark Gray', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Basic Gradient': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Neon City', refill: false, spool: true },
|
||||||
|
{ name: 'Midnight Blaze', refill: false, spool: true },
|
||||||
|
{ name: 'South Beach', refill: false, spool: true },
|
||||||
|
{ name: 'Arctic Whisper', refill: false, spool: true },
|
||||||
|
{ name: 'Cotton Candy Cloud', refill: false, spool: true },
|
||||||
|
{ name: 'Ocean to Meadow', refill: false, spool: true },
|
||||||
|
{ name: 'Solar Breeze', refill: false, spool: true },
|
||||||
|
{ name: 'Velvet Eclipse', refill: false, spool: true },
|
||||||
|
{ name: 'Dawn Radiance', refill: false, spool: true },
|
||||||
|
{ name: 'Dusk Glare', refill: false, spool: true },
|
||||||
|
{ name: 'Blueberry Bubblegum', refill: false, spool: true },
|
||||||
|
{ name: 'Blue Hawaii', refill: false, spool: true },
|
||||||
|
{ name: 'Gilded Rose', refill: false, spool: true },
|
||||||
|
{ name: 'Pink Citrus', refill: false, spool: true },
|
||||||
|
{ name: 'Mint Lime', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Matte: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Matte Ivory White', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Charcoal', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Scarlet Red', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Marine Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Mandarin Orange', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Ash Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Desert Tan', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Nardo Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Apple Green', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Bone White', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Caramel', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Brown', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Chocolate', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Green', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Red', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Grass Green', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Ice Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Lemon Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Lilac Purple', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Plum', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Sakura Pink', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Sky Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Latte Brown', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Terracotta', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Silk+': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Candy Green', refill: false, spool: true },
|
||||||
|
{ name: 'Candy Red', refill: false, spool: true },
|
||||||
|
{ name: 'Mint', refill: false, spool: true },
|
||||||
|
{ name: 'Titan Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Rose Gold', refill: false, spool: true },
|
||||||
|
{ name: 'Champagne', refill: false, spool: true },
|
||||||
|
{ name: 'Baby Blue', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Silk Multi-Color': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Silk Aurora Purple', refill: false, spool: true },
|
||||||
|
{ name: 'Silk Phantom Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Silk Mystic Magenta', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Metal: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Iron Gray Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Iridium Gold Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Cobalt Blue Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Copper Brown Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Oxide Green Metallic', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Sparkle: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Onyx Black Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Classic Gold Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Crimson Red Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Royal Purple Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Slate Gray Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Alpine Green Sparkle', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Galaxy: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Nebulae', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Marble: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'White Marble', refill: false, spool: true },
|
||||||
|
{ name: 'Red Granite', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Glow: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Glow Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Green', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Orange', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Pink', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Yellow', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Wood: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Ochre Yellow', refill: false, spool: true },
|
||||||
|
{ name: 'White Oak', refill: false, spool: true },
|
||||||
|
{ name: 'Clay Brown', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Tough+': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'Burgundy Red', refill: false, spool: true },
|
||||||
|
{ name: 'Jeans Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Lava Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Matcha Green', refill: false, spool: true },
|
||||||
|
{ name: 'Royal Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Iris Purple', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PETG: {
|
||||||
|
HF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Jade White', refill: true, spool: true },
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
{ name: 'Red', refill: true, spool: true },
|
||||||
|
{ name: 'Green', refill: true, spool: true },
|
||||||
|
{ name: 'Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Gray', refill: true, spool: true },
|
||||||
|
{ name: 'Orange', refill: true, spool: true },
|
||||||
|
{ name: 'Cream', refill: true, spool: true },
|
||||||
|
{ name: 'Forest Green', refill: true, spool: true },
|
||||||
|
{ name: 'Lake Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Lime Green', refill: true, spool: true },
|
||||||
|
{ name: 'Peanut Brown', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Translucent: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Clear', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Brown', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Purple', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Orange', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Olive', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Pink', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Light Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Tea', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'Brick Red', refill: false, spool: true },
|
||||||
|
{ name: 'Indigo Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Malachite Green', refill: false, spool: true },
|
||||||
|
{ name: 'Titan Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Violet Purple', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ABS: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'ABS Azure', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Black', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Blue', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Olive', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Tangerine Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Navy Blue', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Orange', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Bambu Green', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Red', refill: true, spool: true },
|
||||||
|
{ name: 'ABS White', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Silver', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
GF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'ABS GF Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'ABS GF Orange', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TPU: {
|
||||||
|
'85A': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Flesh', refill: false, spool: true },
|
||||||
|
{ name: 'Light Cyan', refill: false, spool: true },
|
||||||
|
{ name: 'Neon Orange', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'90A': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Red', refill: false, spool: true },
|
||||||
|
{ name: 'Blaze', refill: false, spool: true },
|
||||||
|
{ name: 'Frozen', refill: false, spool: true },
|
||||||
|
{ name: 'Grape Jelly', refill: false, spool: true },
|
||||||
|
{ name: 'Crystal Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Quicksilver', refill: false, spool: true },
|
||||||
|
{ name: 'Cocoa Brown', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'95A HF': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: true, spool: false },
|
||||||
|
{ name: 'White', refill: true, spool: false },
|
||||||
|
{ name: 'TPU 95A HF Yellow', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ASA: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
{ name: 'Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Gray', refill: true, spool: true },
|
||||||
|
{ name: 'Green', refill: true, spool: true },
|
||||||
|
{ name: 'Red', refill: true, spool: true },
|
||||||
|
{ name: 'White', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Aero: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PA6: {
|
||||||
|
GF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PAHT: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PC: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Clear Black', refill: false, spool: true },
|
||||||
|
{ name: 'Transparent', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FR: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PET: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PPA: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PPS: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
export function getMaterialOptions(): string[] {
|
||||||
|
return Object.keys(BAMBU_LAB_CATALOG).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFinishesForMaterial(material: string): string[] {
|
||||||
|
const materialData = BAMBU_LAB_CATALOG[material];
|
||||||
|
if (!materialData) return [];
|
||||||
|
return Object.keys(materialData).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFinishes(): string[] {
|
||||||
|
const finishes = new Set<string>();
|
||||||
|
for (const material of Object.values(BAMBU_LAB_CATALOG)) {
|
||||||
|
for (const finish of Object.keys(material)) {
|
||||||
|
finishes.add(finish);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...finishes].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorsForMaterialFinish(material: string, finish: string): CatalogColorEntry[] {
|
||||||
|
return BAMBU_LAB_CATALOG[material]?.[finish]?.colors ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorsForMaterial(material: string): CatalogColorEntry[] {
|
||||||
|
const materialData = BAMBU_LAB_CATALOG[material];
|
||||||
|
if (!materialData) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: CatalogColorEntry[] = [];
|
||||||
|
for (const finish of Object.values(materialData)) {
|
||||||
|
for (const color of finish.colors) {
|
||||||
|
if (!seen.has(color.name)) {
|
||||||
|
seen.add(color.name);
|
||||||
|
result.push(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogIsSpoolOnly(material: string, finish: string, color: string): boolean {
|
||||||
|
const entry = BAMBU_LAB_CATALOG[material]?.[finish]?.colors.find(c => c.name === color);
|
||||||
|
return entry ? (entry.spool && !entry.refill) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogIsRefillOnly(material: string, finish: string, color: string): boolean {
|
||||||
|
const entry = BAMBU_LAB_CATALOG[material]?.[finish]?.colors.find(c => c.name === color);
|
||||||
|
return entry ? (entry.refill && !entry.spool) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFinishOptionsForType(type: string): string[] {
|
||||||
|
return getFinishesForMaterial(type);
|
||||||
|
}
|
||||||
@@ -147,11 +147,8 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
|||||||
'Yellow': { hex: '#F4EE2A' },
|
'Yellow': { hex: '#F4EE2A' },
|
||||||
|
|
||||||
// ABS Colors
|
// ABS Colors
|
||||||
// ABS GF Colors
|
|
||||||
'ABS GF Yellow': { hex: '#FDD835' },
|
'ABS GF Yellow': { hex: '#FDD835' },
|
||||||
'ABS GF Orange': { hex: '#F48438' },
|
'ABS GF Orange': { hex: '#F48438' },
|
||||||
|
|
||||||
// ABS Colors
|
|
||||||
'ABS Azure': { hex: '#489FDF' },
|
'ABS Azure': { hex: '#489FDF' },
|
||||||
'ABS Olive': { hex: '#748C45' },
|
'ABS Olive': { hex: '#748C45' },
|
||||||
'ABS Blue': { hex: '#0A2989' },
|
'ABS Blue': { hex: '#0A2989' },
|
||||||
@@ -164,7 +161,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
|||||||
'ABS Black': { hex: '#000000' },
|
'ABS Black': { hex: '#000000' },
|
||||||
'ABS Silver': { hex: '#A6A9AA' },
|
'ABS Silver': { hex: '#A6A9AA' },
|
||||||
|
|
||||||
// Translucent Colors
|
// PETG Translucent Colors
|
||||||
'Translucent Gray': { hex: '#B8B8B8' },
|
'Translucent Gray': { hex: '#B8B8B8' },
|
||||||
'Translucent Brown': { hex: '#C89A74' },
|
'Translucent Brown': { hex: '#C89A74' },
|
||||||
'Translucent Purple': { hex: '#C5A8D8' },
|
'Translucent Purple': { hex: '#C5A8D8' },
|
||||||
@@ -174,7 +171,15 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
|||||||
'Translucent Light Blue': { hex: '#A8D8F0' },
|
'Translucent Light Blue': { hex: '#A8D8F0' },
|
||||||
'Translucent Tea': { hex: '#D9C7A8' },
|
'Translucent Tea': { hex: '#D9C7A8' },
|
||||||
|
|
||||||
// PLA Matte - New Colors (2025)
|
// PLA Matte Colors
|
||||||
|
'Matte Ivory White': { hex: '#FFFFF0' },
|
||||||
|
'Matte Charcoal': { hex: '#333333' },
|
||||||
|
'Matte Scarlet Red': { hex: '#DE4343' },
|
||||||
|
'Matte Marine Blue': { hex: '#0078BF' },
|
||||||
|
'Matte Mandarin Orange': { hex: '#F99963' },
|
||||||
|
'Matte Ash Gray': { hex: '#9B9EA0' },
|
||||||
|
'Matte Desert Tan': { hex: '#E8DBB7' },
|
||||||
|
'Matte Nardo Gray': { hex: '#747474' },
|
||||||
'Matte Apple Green': { hex: '#C6E188' },
|
'Matte Apple Green': { hex: '#C6E188' },
|
||||||
'Matte Bone White': { hex: '#C8C5B6' },
|
'Matte Bone White': { hex: '#C8C5B6' },
|
||||||
'Matte Caramel': { hex: '#A4845C' },
|
'Matte Caramel': { hex: '#A4845C' },
|
||||||
@@ -185,7 +190,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
|||||||
'Matte Dark Red': { hex: '#BB3D43' },
|
'Matte Dark Red': { hex: '#BB3D43' },
|
||||||
'Matte Grass Green': { hex: '#7CB342' },
|
'Matte Grass Green': { hex: '#7CB342' },
|
||||||
'Matte Ice Blue': { hex: '#A3D8E1' },
|
'Matte Ice Blue': { hex: '#A3D8E1' },
|
||||||
'Matte Ivory': { hex: '#FFFFF0' },
|
|
||||||
'Matte Lemon Yellow': { hex: '#F7D959' },
|
'Matte Lemon Yellow': { hex: '#F7D959' },
|
||||||
'Matte Lilac Purple': { hex: '#AE96D4' },
|
'Matte Lilac Purple': { hex: '#AE96D4' },
|
||||||
'Matte Plum': { hex: '#851A52' },
|
'Matte Plum': { hex: '#851A52' },
|
||||||
@@ -199,7 +203,11 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
|||||||
'Silk Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
|
'Silk Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
|
||||||
'Silk Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
|
'Silk Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
|
||||||
|
|
||||||
// TPU 95A HF Colors
|
// TPU Colors
|
||||||
|
'Flesh': { hex: '#E8C4A2' },
|
||||||
|
'Grape Jelly': { hex: '#6B2D75' },
|
||||||
|
'Crystal Blue': { hex: '#5BC0EB' },
|
||||||
|
'Quicksilver': { hex: '#A6A9AA' },
|
||||||
'TPU 95A HF Yellow': { hex: '#F3E600' },
|
'TPU 95A HF Yellow': { hex: '#F3E600' },
|
||||||
|
|
||||||
// Default fallback
|
// Default fallback
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// Complete Bambu Lab color database with hex codes
|
// Complete Bambu Lab color database with hex codes
|
||||||
export const bambuLabColors = {
|
// Re-exports catalog-aligned color groupings
|
||||||
// Basic Colors
|
|
||||||
|
import { BAMBU_LAB_CATALOG } from './bambuLabCatalog';
|
||||||
|
|
||||||
|
// Flat hex lookup (for backwards compatibility)
|
||||||
|
export const bambuLabColors: Record<string, string> = {
|
||||||
|
// PLA Basic
|
||||||
|
"Jade White": "#FFFFFF",
|
||||||
"Black": "#000000",
|
"Black": "#000000",
|
||||||
"White": "#FFFFFF",
|
"White": "#FFFFFF",
|
||||||
"Red": "#E53935",
|
"Red": "#E53935",
|
||||||
@@ -8,39 +14,64 @@ export const bambuLabColors = {
|
|||||||
"Green": "#43A047",
|
"Green": "#43A047",
|
||||||
"Yellow": "#FDD835",
|
"Yellow": "#FDD835",
|
||||||
"Orange": "#FB8C00",
|
"Orange": "#FB8C00",
|
||||||
"Purple": "#8E24AA",
|
"Purple": "#5E43B7",
|
||||||
"Pink": "#EC407A",
|
"Pink": "#F55A74",
|
||||||
"Grey": "#757575",
|
"Gray": "#8E9089",
|
||||||
"Brown": "#6D4C41",
|
"Brown": "#9D432C",
|
||||||
"Light Blue": "#64B5F6",
|
"Light Blue": "#61B0FF",
|
||||||
"Light Green": "#81C784",
|
"Light Gray": "#D0D2D4",
|
||||||
"Mint Green": "#4DB6AC",
|
"Sky Blue": "#73B2E5",
|
||||||
"Lime Green": "#C0CA33",
|
"Navy Blue": "#0C2340",
|
||||||
"Sky Blue": "#81D4FA",
|
"Magenta": "#EC008C",
|
||||||
"Navy Blue": "#283593",
|
"Beige": "#F7E6DE",
|
||||||
"Magenta": "#E91E63",
|
"Bambu Green": "#00AE42",
|
||||||
"Violet": "#7B1FA2",
|
"Scarlet Red": "#DE4343",
|
||||||
"Beige": "#F5DEB3",
|
"Lemon Yellow": "#F7D959",
|
||||||
"Ivory": "#FFFFF0",
|
"Cyan": "#0086D6",
|
||||||
|
"Sakura Pink": "#E8AFCF",
|
||||||
|
"Cobalt Blue": "#0055B8",
|
||||||
|
"Mistletoe Green": "#3F8E43",
|
||||||
|
"Dark Red": "#BB3D43",
|
||||||
|
"Hot Pink": "#F5547D",
|
||||||
|
"Lavender": "#B5AAD5",
|
||||||
|
"Sunflower Yellow": "#FEC601",
|
||||||
|
"Pumpkin Orange": "#FF8E16",
|
||||||
|
"Lime": "#C5ED48",
|
||||||
|
"Blue Grey": "#5B6579",
|
||||||
|
"Gold": "#E4BD68",
|
||||||
|
"Bright Green": "#BDCF00",
|
||||||
|
"Maroon Red": "#0A2989",
|
||||||
|
"Turquoise": "#00B1B7",
|
||||||
|
"Bronze": "#847D48",
|
||||||
|
"Silver": "#A6A9AA",
|
||||||
|
"Dark Gray": "#555555",
|
||||||
|
|
||||||
// Matte Colors
|
// PLA Basic Gradient
|
||||||
"Matte Black": "#212121",
|
"Neon City": "#0047BB",
|
||||||
"Matte White": "#FAFAFA",
|
"Midnight Blaze": "#0047BB",
|
||||||
"Matte Red": "#C62828",
|
"South Beach": "#468791",
|
||||||
"Matte Blue": "#1565C0",
|
"Arctic Whisper": "#ECF7F8",
|
||||||
"Matte Green": "#2E7D32",
|
"Cotton Candy Cloud": "#E9E2EC",
|
||||||
"Matte Yellow": "#F9A825",
|
"Ocean to Meadow": "#A1E4CA",
|
||||||
"Matte Orange": "#EF6C00",
|
"Solar Breeze": "#F3D9D5",
|
||||||
"Matte Purple": "#6A1B9A",
|
"Velvet Eclipse": "#000000",
|
||||||
"Matte Pink": "#D81B60",
|
"Dawn Radiance": "#C472A1",
|
||||||
"Matte Grey": "#616161",
|
"Dusk Glare": "#F6B790",
|
||||||
"Matte Brown": "#4E342E",
|
"Blueberry Bubblegum": "#BADCF4",
|
||||||
"Matte Mint": "#26A69A",
|
"Blue Hawaii": "#739FE6",
|
||||||
"Matte Lime": "#9E9D24",
|
"Gilded Rose": "#ED982C",
|
||||||
"Matte Navy": "#1A237E",
|
"Pink Citrus": "#F8C4BC",
|
||||||
"Matte Coral": "#FF5252",
|
"Mint Lime": "#BAF382",
|
||||||
|
|
||||||
// Matte Colors - New 2025
|
// PLA Matte
|
||||||
|
"Matte Ivory White": "#FFFFF0",
|
||||||
|
"Matte Charcoal": "#333333",
|
||||||
|
"Matte Scarlet Red": "#DE4343",
|
||||||
|
"Matte Marine Blue": "#0078BF",
|
||||||
|
"Matte Mandarin Orange": "#F99963",
|
||||||
|
"Matte Ash Gray": "#9B9EA0",
|
||||||
|
"Matte Desert Tan": "#E8DBB7",
|
||||||
|
"Matte Nardo Gray": "#747474",
|
||||||
"Matte Apple Green": "#C6E188",
|
"Matte Apple Green": "#C6E188",
|
||||||
"Matte Bone White": "#C8C5B6",
|
"Matte Bone White": "#C8C5B6",
|
||||||
"Matte Caramel": "#A4845C",
|
"Matte Caramel": "#A4845C",
|
||||||
@@ -49,7 +80,7 @@ export const bambuLabColors = {
|
|||||||
"Matte Dark Chocolate": "#4A3729",
|
"Matte Dark Chocolate": "#4A3729",
|
||||||
"Matte Dark Green": "#68724D",
|
"Matte Dark Green": "#68724D",
|
||||||
"Matte Dark Red": "#BB3D43",
|
"Matte Dark Red": "#BB3D43",
|
||||||
"Matte Grass Green": "#61C680",
|
"Matte Grass Green": "#7CB342",
|
||||||
"Matte Ice Blue": "#A3D8E1",
|
"Matte Ice Blue": "#A3D8E1",
|
||||||
"Matte Lemon Yellow": "#F7D959",
|
"Matte Lemon Yellow": "#F7D959",
|
||||||
"Matte Lilac Purple": "#AE96D4",
|
"Matte Lilac Purple": "#AE96D4",
|
||||||
@@ -59,131 +90,146 @@ export const bambuLabColors = {
|
|||||||
"Matte Latte Brown": "#D3B7A7",
|
"Matte Latte Brown": "#D3B7A7",
|
||||||
"Matte Terracotta": "#A25A37",
|
"Matte Terracotta": "#A25A37",
|
||||||
|
|
||||||
// Silk Colors
|
// PLA Silk+
|
||||||
"Silk White": "#FEFEFE",
|
"Candy Green": "#408619",
|
||||||
"Silk Black": "#0A0A0A",
|
"Candy Red": "#BB3A2E",
|
||||||
"Silk Red": "#F44336",
|
"Mint": "#A5DAB7",
|
||||||
"Silk Blue": "#2196F3",
|
"Titan Gray": "#606367",
|
||||||
"Silk Green": "#4CAF50",
|
"Rose Gold": "#B29593",
|
||||||
"Silk Gold": "#FFD54F",
|
"Champagne": "#EBD0B1",
|
||||||
"Silk Silver": "#CFD8DC",
|
"Baby Blue": "#AEC3ED",
|
||||||
"Silk Purple": "#9C27B0",
|
|
||||||
"Silk Pink": "#F06292",
|
|
||||||
"Silk Orange": "#FF9800",
|
|
||||||
"Silk Bronze": "#A1887F",
|
|
||||||
"Silk Copper": "#BF6F3F",
|
|
||||||
"Silk Jade": "#00897B",
|
|
||||||
"Silk Rose Gold": "#E8A09A",
|
|
||||||
"Silk Pearl": "#F8F8FF",
|
|
||||||
"Silk Ruby": "#E91E63",
|
|
||||||
"Silk Sapphire": "#1976D2",
|
|
||||||
"Silk Emerald": "#00695C",
|
|
||||||
|
|
||||||
// Silk Multi-Color
|
// PLA Silk Multi-Color
|
||||||
"Silk Aurora Purple": "#7F3696",
|
"Silk Aurora Purple": "#7F3696",
|
||||||
"Silk Phantom Blue": "#00629B",
|
"Silk Phantom Blue": "#00629B",
|
||||||
"Silk Mystic Magenta": "#720062",
|
"Silk Mystic Magenta": "#720062",
|
||||||
|
|
||||||
// Metal Colors
|
// PLA Metal
|
||||||
"Metal Grey": "#9E9E9E",
|
|
||||||
"Metal Silver": "#B0BEC5",
|
|
||||||
"Metal Gold": "#D4AF37",
|
|
||||||
"Metal Copper": "#B87333",
|
|
||||||
"Metal Bronze": "#CD7F32",
|
|
||||||
|
|
||||||
// Sparkle Colors
|
|
||||||
"Sparkle Red": "#EF5350",
|
|
||||||
"Sparkle Blue": "#42A5F5",
|
|
||||||
"Sparkle Green": "#66BB6A",
|
|
||||||
"Sparkle Purple": "#AB47BC",
|
|
||||||
"Sparkle Gold": "#FFCA28",
|
|
||||||
"Sparkle Silver": "#E0E0E0",
|
|
||||||
|
|
||||||
// Glow Colors
|
|
||||||
"Glow in the Dark Green": "#C8E6C9",
|
|
||||||
"Glow in the Dark Blue": "#BBDEFB",
|
|
||||||
|
|
||||||
// Transparent Colors
|
|
||||||
"Clear": "#FFFFFF",
|
|
||||||
"Transparent Red": "#EF5350",
|
|
||||||
"Transparent Blue": "#42A5F5",
|
|
||||||
"Transparent Green": "#66BB6A",
|
|
||||||
"Transparent Yellow": "#FFEE58",
|
|
||||||
"Transparent Orange": "#FFA726",
|
|
||||||
"Transparent Purple": "#AB47BC",
|
|
||||||
|
|
||||||
// Support Materials
|
|
||||||
"Natural": "#F5F5DC",
|
|
||||||
"Support White": "#F5F5F5",
|
|
||||||
"Support G": "#90CAF9",
|
|
||||||
|
|
||||||
// Metal Colors (PLA)
|
|
||||||
"Iron Gray Metallic": "#6B6C6F",
|
"Iron Gray Metallic": "#6B6C6F",
|
||||||
|
"Iridium Gold Metallic": "#B39B84",
|
||||||
|
"Cobalt Blue Metallic": "#39699E",
|
||||||
|
"Copper Brown Metallic": "#AA6443",
|
||||||
|
"Oxide Green Metallic": "#1D7C6A",
|
||||||
|
|
||||||
// ABS GF Colors
|
// PLA Sparkle
|
||||||
|
"Onyx Black Sparkle": "#2D2B28",
|
||||||
|
"Classic Gold Sparkle": "#E4BD68",
|
||||||
|
"Crimson Red Sparkle": "#792B36",
|
||||||
|
"Royal Purple Sparkle": "#483D8B",
|
||||||
|
"Slate Gray Sparkle": "#8E9089",
|
||||||
|
"Alpine Green Sparkle": "#3F5443",
|
||||||
|
|
||||||
|
// PLA Galaxy
|
||||||
|
"Nebulae": "#424379",
|
||||||
|
|
||||||
|
// PLA Marble
|
||||||
|
"White Marble": "#F7F3F0",
|
||||||
|
"Red Granite": "#AD4E38",
|
||||||
|
|
||||||
|
// PLA Glow
|
||||||
|
"Glow Blue": "#7AC0E9",
|
||||||
|
"Glow Green": "#A1FFAC",
|
||||||
|
"Glow Orange": "#FF9D5B",
|
||||||
|
"Glow Pink": "#F17B8F",
|
||||||
|
"Glow Yellow": "#F8FF80",
|
||||||
|
|
||||||
|
// PLA Wood
|
||||||
|
"Ochre Yellow": "#BC8B39",
|
||||||
|
"White Oak": "#D2CCA2",
|
||||||
|
"Clay Brown": "#8E621A",
|
||||||
|
|
||||||
|
// PLA CF
|
||||||
|
"Burgundy Red": "#951E23",
|
||||||
|
"Jeans Blue": "#6E88BC",
|
||||||
|
"Lava Gray": "#4D5054",
|
||||||
|
"Matcha Green": "#5C9748",
|
||||||
|
"Royal Blue": "#2842AD",
|
||||||
|
"Iris Purple": "#69398E",
|
||||||
|
|
||||||
|
// PETG HF
|
||||||
|
"Cream": "#F3E0B8",
|
||||||
|
"Forest Green": "#415520",
|
||||||
|
"Lake Blue": "#4672E4",
|
||||||
|
"Lime Green": "#8EE43D",
|
||||||
|
"Peanut Brown": "#7E5A1F",
|
||||||
|
|
||||||
|
// PETG Translucent
|
||||||
|
"Clear": "#FAFAFA",
|
||||||
|
"Translucent Gray": "#B8B8B8",
|
||||||
|
"Translucent Brown": "#C89A74",
|
||||||
|
"Translucent Purple": "#C5A8D8",
|
||||||
|
"Translucent Orange": "#FFB380",
|
||||||
|
"Translucent Olive": "#A4B885",
|
||||||
|
"Translucent Pink": "#F9B8D0",
|
||||||
|
"Translucent Light Blue": "#A8D8F0",
|
||||||
|
"Translucent Tea": "#D9C7A8",
|
||||||
|
|
||||||
|
// PETG CF
|
||||||
|
"Brick Red": "#9F332A",
|
||||||
|
"Indigo Blue": "#324585",
|
||||||
|
"Malachite Green": "#16B08E",
|
||||||
|
"Violet Purple": "#583061",
|
||||||
|
|
||||||
|
// ABS
|
||||||
|
"ABS Azure": "#489FDF",
|
||||||
|
"ABS Black": "#000000",
|
||||||
|
"ABS Blue": "#0A2989",
|
||||||
|
"ABS Olive": "#748C45",
|
||||||
|
"ABS Tangerine Yellow": "#FFC72C",
|
||||||
|
"ABS Navy Blue": "#0C2340",
|
||||||
|
"ABS Orange": "#FF6A13",
|
||||||
|
"ABS Bambu Green": "#00AE42",
|
||||||
|
"ABS Red": "#C12E1F",
|
||||||
|
"ABS White": "#FFFFFF",
|
||||||
|
"ABS Silver": "#A6A9AA",
|
||||||
"ABS GF Yellow": "#FDD835",
|
"ABS GF Yellow": "#FDD835",
|
||||||
"ABS GF Orange": "#F48438",
|
"ABS GF Orange": "#F48438",
|
||||||
|
|
||||||
// TPU 95A HF Colors
|
// TPU
|
||||||
|
"Flesh": "#E8C4A2",
|
||||||
|
"Light Cyan": "#B9E3DF",
|
||||||
|
"Neon Orange": "#F68A1B",
|
||||||
|
"Blaze": "#E78390",
|
||||||
|
"Frozen": "#A6DEF3",
|
||||||
|
"Grape Jelly": "#6B2D75",
|
||||||
|
"Crystal Blue": "#5BC0EB",
|
||||||
|
"Quicksilver": "#A6A9AA",
|
||||||
|
"Cocoa Brown": "#6F5034",
|
||||||
"TPU 95A HF Yellow": "#F3E600",
|
"TPU 95A HF Yellow": "#F3E600",
|
||||||
|
|
||||||
// Wood Colors
|
// PC
|
||||||
"Ochre Yellow": "#BC8B39",
|
"Clear Black": "#5A5161",
|
||||||
"White Oak": "#D2CCA2",
|
"Transparent": "#FFFFFF",
|
||||||
"Clay Brown": "#8E621A"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Colors grouped by finish type for easier selection
|
// Colors grouped by finish type — derived from catalog
|
||||||
export const colorsByFinish = {
|
export const colorsByFinish: Record<string, string[]> = {};
|
||||||
"Basic": [
|
|
||||||
"Black", "White", "Red", "Blue", "Green", "Yellow", "Orange",
|
// Build colorsByFinish from catalog
|
||||||
"Purple", "Pink", "Grey", "Brown", "Light Blue", "Light Green",
|
for (const [, finishes] of Object.entries(BAMBU_LAB_CATALOG)) {
|
||||||
"Mint Green", "Lime Green", "Sky Blue", "Navy Blue", "Magenta",
|
for (const [finishName, finishData] of Object.entries(finishes)) {
|
||||||
"Violet", "Beige", "Ivory"
|
if (!colorsByFinish[finishName]) {
|
||||||
],
|
colorsByFinish[finishName] = [];
|
||||||
"Matte": [
|
}
|
||||||
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
|
for (const color of finishData.colors) {
|
||||||
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
|
if (!colorsByFinish[finishName].includes(color.name)) {
|
||||||
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral",
|
colorsByFinish[finishName].push(color.name);
|
||||||
"Matte Apple Green", "Matte Bone White", "Matte Caramel", "Matte Dark Blue",
|
}
|
||||||
"Matte Dark Brown", "Matte Dark Chocolate", "Matte Dark Green", "Matte Dark Red",
|
}
|
||||||
"Matte Grass Green", "Matte Ice Blue", "Matte Lemon Yellow", "Matte Lilac Purple",
|
}
|
||||||
"Matte Latte Brown", "Matte Plum", "Matte Sakura Pink", "Matte Sky Blue", "Matte Terracotta"
|
}
|
||||||
],
|
|
||||||
"Silk": [
|
// Sort each finish's colors
|
||||||
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",
|
for (const finish of Object.keys(colorsByFinish)) {
|
||||||
"Silk Gold", "Silk Silver", "Silk Purple", "Silk Pink", "Silk Orange",
|
colorsByFinish[finish].sort();
|
||||||
"Silk Bronze", "Silk Copper", "Silk Jade", "Silk Rose Gold", "Silk Pearl",
|
}
|
||||||
"Silk Ruby", "Silk Sapphire", "Silk Emerald"
|
|
||||||
],
|
|
||||||
"Metal": [
|
|
||||||
"Metal Grey", "Metal Silver", "Metal Gold", "Metal Copper", "Metal Bronze"
|
|
||||||
],
|
|
||||||
"Sparkle": [
|
|
||||||
"Sparkle Red", "Sparkle Blue", "Sparkle Green", "Sparkle Purple",
|
|
||||||
"Sparkle Gold", "Sparkle Silver"
|
|
||||||
],
|
|
||||||
"Glow": [
|
|
||||||
"Glow in the Dark Green", "Glow in the Dark Blue"
|
|
||||||
],
|
|
||||||
"Transparent": [
|
|
||||||
"Clear", "Transparent Red", "Transparent Blue", "Transparent Green",
|
|
||||||
"Transparent Yellow", "Transparent Orange", "Transparent Purple"
|
|
||||||
],
|
|
||||||
"Support": [
|
|
||||||
"Natural", "Support White", "Support G"
|
|
||||||
],
|
|
||||||
"Wood": [
|
|
||||||
"Ochre Yellow", "White Oak", "Clay Brown"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to get hex code for a color
|
// Function to get hex code for a color
|
||||||
export function getColorHex(colorName: string): string {
|
export function getColorHex(colorName: string): string {
|
||||||
return bambuLabColors[colorName as keyof typeof bambuLabColors] || "#000000";
|
return bambuLabColors[colorName] || "#000000";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to get colors for a specific finish
|
// Function to get colors for a specific finish
|
||||||
export function getColorsForFinish(finish: string): string[] {
|
export function getColorsForFinish(finish: string): string[] {
|
||||||
return colorsByFinish[finish as keyof typeof colorsByFinish] || [];
|
return colorsByFinish[finish] || [];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Filament } from '@/src/types/filament';
|
import { Filament } from '@/src/types/filament';
|
||||||
|
import { Customer, Sale, CreateSaleRequest, AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||||
|
|
||||||
@@ -132,4 +133,79 @@ export const colorRequestService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const customerService = {
|
||||||
|
getAll: async (): Promise<Customer[]> => {
|
||||||
|
const response = await api.get('/customers');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
search: async (q: string): Promise<Customer[]> => {
|
||||||
|
const response = await api.get(`/customers/search?q=${encodeURIComponent(q)}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Customer & { sales: Sale[] }> => {
|
||||||
|
const response = await api.get(`/customers/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (customer: Partial<Customer>): Promise<Customer> => {
|
||||||
|
const response = await api.post('/customers', customer);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, customer: Partial<Customer>): Promise<Customer> => {
|
||||||
|
const response = await api.put(`/customers/${id}`, customer);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saleService = {
|
||||||
|
getAll: async (page = 1, limit = 50): Promise<{ sales: Sale[]; total: number }> => {
|
||||||
|
const response = await api.get(`/sales?page=${page}&limit=${limit}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Sale> => {
|
||||||
|
const response = await api.get(`/sales/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateSaleRequest): Promise<Sale> => {
|
||||||
|
const response = await api.post('/sales', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/sales/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const analyticsService = {
|
||||||
|
getOverview: async (period = '30d'): Promise<AnalyticsOverview> => {
|
||||||
|
const response = await api.get(`/analytics/overview?period=${period}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTopSellers: async (period = '30d'): Promise<TopSeller[]> => {
|
||||||
|
const response = await api.get(`/analytics/top-sellers?period=${period}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getInventoryAlerts: async (): Promise<InventoryAlert[]> => {
|
||||||
|
const response = await api.get('/analytics/inventory-alerts');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRevenueChart: async (period = '6m', group = 'month'): Promise<RevenueDataPoint[]> => {
|
||||||
|
const response = await api.get(`/analytics/revenue-chart?period=${period}&group=${group}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTypeBreakdown: async (period = '30d'): Promise<TypeBreakdown[]> => {
|
||||||
|
const response = await api.get(`/analytics/type-breakdown?period=${period}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
91
src/types/sales.ts
Normal file
91
src/types/sales.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
export interface Customer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
city?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaleItem {
|
||||||
|
id: string;
|
||||||
|
sale_id: string;
|
||||||
|
filament_id: string;
|
||||||
|
item_type: 'refill' | 'spulna';
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
created_at?: string;
|
||||||
|
// Joined fields
|
||||||
|
filament_tip?: string;
|
||||||
|
filament_finish?: string;
|
||||||
|
filament_boja?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sale {
|
||||||
|
id: string;
|
||||||
|
customer_id?: string;
|
||||||
|
total_amount: number;
|
||||||
|
notes?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
// Joined fields
|
||||||
|
customer_name?: string;
|
||||||
|
customer_phone?: string;
|
||||||
|
items?: SaleItem[];
|
||||||
|
item_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSaleRequest {
|
||||||
|
customer: {
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
city?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
items: {
|
||||||
|
filament_id: string;
|
||||||
|
item_type: 'refill' | 'spulna';
|
||||||
|
quantity: number;
|
||||||
|
}[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverview {
|
||||||
|
revenue: number;
|
||||||
|
sales_count: number;
|
||||||
|
avg_order_value: number;
|
||||||
|
unique_customers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopSeller {
|
||||||
|
boja: string;
|
||||||
|
tip: string;
|
||||||
|
finish: string;
|
||||||
|
total_qty: number;
|
||||||
|
total_revenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueDataPoint {
|
||||||
|
period: string;
|
||||||
|
revenue: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryAlert {
|
||||||
|
id: string;
|
||||||
|
boja: string;
|
||||||
|
tip: string;
|
||||||
|
finish: string;
|
||||||
|
refill: number;
|
||||||
|
spulna: number;
|
||||||
|
kolicina: number;
|
||||||
|
avg_daily_sales: number;
|
||||||
|
days_until_stockout: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeBreakdown {
|
||||||
|
item_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
total_revenue: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user